Skip to content

Update the generic collection types to internally use GC.AllocateUninitializedArray<T> - #47186

Closed
tannergooding wants to merge 8 commits into
dotnet:masterfrom
tannergooding:uninit-array
Closed

Update the generic collection types to internally use GC.AllocateUninitializedArray<T>#47186
tannergooding wants to merge 8 commits into
dotnet:masterfrom
tannergooding:uninit-array

Conversation

@tannergooding

@tannergoodingtannergooding commented Jan 19, 2021

Copy link
Copy Markdown
Member

As per the title, this updates the generic collection types to internally use GC.AllocateUninitializedArray.

When T is a reference type, this should not differ from the standard new T[] as the API ensures the array is zeroed anyways for correctness.
However, when T is a value type and particularly when the size of T or the collection is large, this can help avoid needless zeroing in the underlying APIs.

Most of the collection types are already optimized to only zero removed elements if T is a reference type and internally track and check their bounds so this should be completely safe.

For a simple test, such as System.Collections.CtorGivenSize.List which creates a List with a capacity of 512, this shows some small gains:

BenchmarkDotNet=v0.12.1.1466-nightly, OS=Windows 10.0.19042
AMD Ryzen 9 5950X, 1 CPU, 32 logical and 16 physical cores
.NET SDK=6.0.100-alpha.1.21056.1
[Host] : .NET 6.0.0 (6.0.21.5406), X64 RyuJIT
Job-RSPOMU : .NET 6.0.0 (42.42.42.42424), X64 RyuJIT
Job-ECGLUA : .NET 6.0.0 (42.42.42.42424), X64 RyuJIT
PowerPlanMode=00000000-0000-0000-0000-000000000000 Arguments=/p:DebugType=portable IterationTime=250.0000 ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodJobToolchainSizeMeanErrorStdDevMedianMinMaxRatioRatioSDGen 0Gen 1Gen 2Allocated
ListJob-RSPOMU\runtime\artifacts\bin\testhost\net6.0-windows-Release-x64\shared\Microsoft.NETCore.App\6.0.0\CoreRun.exe51265.10 ns0.783 ns0.694 ns65.12 ns64.24 ns66.48 ns1.370.040.12560.0008-2 KB
ListJob-ECGLUA\runtime_base\artifacts\bin\testhost\net6.0-windows-Release-x64\shared\Microsoft.NETCore.App\6.0.0\CoreRun.exe51247.48 ns1.022 ns1.136 ns47.29 ns46.09 ns50.31 ns1.000.000.12560.0019-2 KB

For string, it is likewise showing essentially no difference:

MethodJobToolchainSizeMeanErrorStdDevMedianMinMaxRatioRatioSDGen 0Gen 1Gen 2Allocated
ListJob-RSPOMU\runtime\artifacts\bin\testhost\net6.0-windows-Release-x64\shared\Microsoft.NETCore.App\6.0.0\CoreRun.exe51298.22 ns2.805 ns2.881 ns98.63 ns93.72 ns103.37 ns1.040.040.24800.0072-4 KB
ListJob-ECGLUA\runtime_base\artifacts\bin\testhost\net6.0-windows-Release-x64\shared\Microsoft.NETCore.App\6.0.0\CoreRun.exe51294.07 ns1.643 ns1.537 ns94.72 ns91.21 ns96.11 ns1.000.000.24810.0074-4 KB

I haven't run the full suite of performance tests, but similar gains should be seen for effectively any value type for the given collections.

@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @eiriktsarpalis
See info in area-owners.md if you want to be subscribed.

Issue Details

As per the title, this updates the generic collection types to internally use GC.AllocateUninitializedArray.

When T is a reference type, this should not differ from the standard new T[] as the API ensures the array is zeroed anyways for correctness.
However, when T is a value type and particularly when the size of T or the collection is large, this can help avoid needless zeroing in the underlying APIs.

Most of the collection types are already optimized to only zero removed elements if T is a reference type and internally track and check their bounds so this should be completely safe.

For a simple test, such as System.Collections.CtorGivenSize.List which creates a List with a capacity of 512, this shows some small gains:

BenchmarkDotNet=v0.12.1.1466-nightly, OS=Windows 10.0.19042
AMD Ryzen 9 5950X, 1 CPU, 32 logical and 16 physical cores
.NET SDK=6.0.100-alpha.1.21056.1
[Host] : .NET 6.0.0 (6.0.21.5406), X64 RyuJIT
Job-RSPOMU : .NET 6.0.0 (42.42.42.42424), X64 RyuJIT
Job-ECGLUA : .NET 6.0.0 (42.42.42.42424), X64 RyuJIT
PowerPlanMode=00000000-0000-0000-0000-000000000000 Arguments=/p:DebugType=portable IterationTime=250.0000 ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodJobToolchainSizeMeanErrorStdDevMedianMinMaxRatioRatioSDGen 0Gen 1Gen 2Allocated
ListJob-RSPOMU\runtime\artifacts\bin\testhost\net6.0-windows-Release-x64\shared\Microsoft.NETCore.App\6.0.0\CoreRun.exe51265.10 ns0.783 ns0.694 ns65.12 ns64.24 ns66.48 ns1.370.040.12560.0008-2 KB
ListJob-ECGLUA\runtime_base\artifacts\bin\testhost\net6.0-windows-Release-x64\shared\Microsoft.NETCore.App\6.0.0\CoreRun.exe51247.48 ns1.022 ns1.136 ns47.29 ns46.09 ns50.31 ns1.000.000.12560.0019-2 KB

For string, it is likewise showing essentially no difference:

MethodJobToolchainSizeMeanErrorStdDevMedianMinMaxRatioRatioSDGen 0Gen 1Gen 2Allocated
ListJob-RSPOMU\runtime\artifacts\bin\testhost\net6.0-windows-Release-x64\shared\Microsoft.NETCore.App\6.0.0\CoreRun.exe51298.22 ns2.805 ns2.881 ns98.63 ns93.72 ns103.37 ns1.040.040.24800.0072-4 KB
ListJob-ECGLUA\runtime_base\artifacts\bin\testhost\net6.0-windows-Release-x64\shared\Microsoft.NETCore.App\6.0.0\CoreRun.exe51297.07 ns1.643 ns1.537 ns94.72 ns91.21 ns96.11 ns1.000.000.24810.0074-4 KB

I haven't run the full suite of performance tests, but similar gains should be seen for effectively any value type for the given collections.

Author:tannergooding
Assignees:-
Labels:

area-System.Collections

Milestone:-

@tannergooding

Copy link
Copy Markdown
MemberAuthor

CC. @stephentoub, @jkotas

@GrabYourPitchforks

GrabYourPitchforks commented Jan 19, 2021

Copy link
Copy Markdown
Member

This may potentially turn race conditions into memory safety violations. Consider: multiple threads are improperly attempting to access a List<T>. With today's implementation, it's possible that they can corrupt the internal state of the List<T> and stomp on each others' data. This could result in some strange behaviors, such as a thread improperly reading default(T) or a stale T from the list. But it cannot possibly result in a thread reading an uninitializedT from the list.

With the new implementation, since the underlying array is now backed by potentially uninitialized memory, a racing thread may improperly read an uninitializedT from the underlying array. This is a possible memory safety violation because it may result in the disclosure of an arbitrary memory address's contents.

@tannergooding

tannergooding commented Jan 19, 2021

Copy link
Copy Markdown
MemberAuthor

But it cannot possibly result in a thread reading an uninitialized T from the list.

It can because all of the types, modulo SortedSet, have code such as the following:

if(RuntimeHelpers.IsReferenceOrContainsReferences<T>()){_items[_size]=default!;}
if(RuntimeHelpers.IsReferenceOrContainsReferences<T>()){Array.Clear(_items,freeIndex,_size-freeIndex);// Clear the elements so that the gc can reclaim the references.}

etc

So we already have situations today where parts of the collection are uninitialized. This just makes it so that all elements start that way.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Ah, nevermind. I misinterpreted "uninitialized" here.

Is that actually a concern? Any user could define their own collection type using GC.AllocUninitializedArray, etc and could have the "same" side effect and the same view of memory.
Likewise with reading "stale" data from an existing collection type.

@jkotas

Copy link
Copy Markdown
Member

System.Collections.CtorGivenSize.List

This micro-benchmark is not representative of actual user scenario. Creating an empty list and doing nothing with it is not interesting.

What is the improvement for the case you actually try to do something with the list?

Is this going to regress cases where you are creating small lists, e.g. list with just a few elements that are the 99% scenario for collections according to the telemetry?

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Is this going to regress cases where you are creating small lists, e.g. list with just a few elements that are the 99% scenario for collections according to the telemetry?

I'll check....

At least for when T is a reference type it is optimized to be just new T[] and the JIT emits the following as expected (same codegen as new T[]):

movrcx,0xD1FFAB1Emovedx, <size>call CORINFO_HELP_NEWARR_1_OBJ

For when T is a value type, it looks like it generates less efficient code:

movrcx,0xD1FFAB1Ecall CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPEmov gword ptr [rsp+20H],raxlearcx, bword ptr [rsp+20H]call System.RuntimeTypeHandle:get_Value():long:thismovrcx,raxmovedx, <size>movr8d,16call System.GC:AllocateNewArray(long,int,int):System.Array

Likewise, if optimizations aren't enabled it is ultimately a call to AllocateUninitializedArray. However, I think both of these would be addressable by making the method an Intrinsic.
Given the gains on big collections of value types, particularly int, aren't uncommon, this seems like something worth pursuing.

@jkotas

Copy link
Copy Markdown
Member

we already have situations today where parts of the collection are uninitialized.

Today, all parts of the collections are either zero-initialized or initialized to a valid value. This change makes it possible to see values that are complete garbage.

I agree with @GrabYourPitchforks that this weakens security defense-in-depth in the presence of race conditions.

@GrabYourPitchforks

Copy link
Copy Markdown
Member

GC.AllocateUninitializedArray is an unsafe-equivalent API. When it's used as an internal implementation detail it's normally fine. If a user chooses to call this in their own code, it's on them to ensure safe usage. But if there's any chance that this implementation detail might leak to an external caller (such as the caller triggering a race condition), we need to proceed with caution.

There are some cases where we're ok with these implementation details possibly being exposed to external callers. ArrayPool<T>.Shared.Rent is the most prominent example. And it might be ok here as well if we're willing to say "you know what, it's only a bad thing if the caller already has a race condition, and we're willing to sacrifice some memory safety there in order to eke out better performance." But those statements really should be explicit. This also came up offhandedly in the StringBuilder PR, but I think the PR review there didn't identify any places where the uninitialized data could ever leak to the caller, even in the face of race conditions.

@stephentoub

stephentoub commented Jan 19, 2021

Copy link
Copy Markdown
Member

It's trivial today with a type like List<T> for erroneous code to see values that were never there, e.g.

usingSystem;usingSystem.Collections.Generic;usingSystem.Threading.Tasks;classProgram{staticvoidMain(){varlist=newList<(long,long)>();list.Add((1,1));Task.Run(()=>{while(true){list[0]=(1,1);}});Task.Run(()=>{while(true){list[0]=(2,2);}});while(true){varitem=list[0];if(item.Item1!=item.Item2){Console.WriteLine(item);}}}}

Every single item stored into the list has the exact same value in each item, but because of tearing the reader can see unequal values. From a "This change makes it possible to see values that are complete garbage" perspective, how is this substantially different? Yes, you could see arbitrary bytes that were never written, but from the perspective "could the state here be valid instance of the value type", it seems like it's the same impact.

@jkotas

jkotas commented Jan 19, 2021

Copy link
Copy Markdown
Member

From a "This change makes it possible to see values that are complete garbage" perspective, how is this substantially different?

For example, you can potentially use a race condition like this for information disclosure attack after this change.

@En3Tho

En3Tho commented Jan 19, 2021

Copy link
Copy Markdown
Contributor

Why would jit produce all that code for structs instead of a GC allocate call like with reference types? I might be wrong but it tries to get size from runtime handle first? Isn't it known at jit time?

@stephentoub

stephentoub commented Jan 19, 2021

Copy link
Copy Markdown
Member

information disclosure

So the concern is that some code put sensitive data into an array from the pool (whether directly or indirectly via some component it was using), didn't clear it when returning (which is feasible since not clearing is the default and most code doesn't clear), that array is consumed into a collection with a bug that manifest as a race condition that could enable a slot that wasn't written on that instance to be read, and an attacker could manipulate the app into hitting that race condition and surfacing that data.

Ok.

EDIT: My mind went to ArrayPool as it was cited earlier and I forgot we were talking about AllocateUninitializedArray, but obviously it's even easier as GC.AllocateUninitializedArray doesn't require explicit pooling.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Benchmarking new T[] vs GC.AllocateUninitializedArray<T> shows the below. Essentially, provided the number of bytes being allocated is under 2048 bytes, it's the "same" speed (effectively within 0.5ns) because AllocateUninitializedArray decides to just call new T[] in this scenario.
Above 2048 bytes (so 512 elements for int, 128 elements for Vector128<T>, etc) you start seeing minimal gains. Once it hits 4096 bytes, the allocations become almost twice as fast.

Part of this overhead is due to additional logic introduced by inlining/remove the logic around: AllocateUninitializedArray. Part of the overhead is also related to #5973

Int32

MethodSizeMeanErrorStdDev
InitializedArray02.268 ns0.0949 ns0.1165 ns
UninitializedArray02.394 ns0.0281 ns0.0235 ns
InitializedArray12.434 ns0.0856 ns0.0715 ns
UninitializedArray12.661 ns0.0512 ns0.0454 ns
InitializedArray22.290 ns0.0362 ns0.0321 ns
UninitializedArray22.673 ns0.0236 ns0.0209 ns
InitializedArray42.501 ns0.0372 ns0.0330 ns
UninitializedArray42.919 ns0.0447 ns0.0373 ns
InitializedArray83.020 ns0.0300 ns0.0266 ns
UninitializedArray83.468 ns0.1154 ns0.1185 ns
InitializedArray163.815 ns0.0381 ns0.0357 ns
UninitializedArray164.099 ns0.0489 ns0.0382 ns
InitializedArray325.555 ns0.0499 ns0.0467 ns
UninitializedArray325.835 ns0.0886 ns0.0786 ns
InitializedArray648.493 ns0.0625 ns0.0488 ns
UninitializedArray648.633 ns0.0693 ns0.0614 ns
InitializedArray12814.906 ns0.3316 ns0.3257 ns
UninitializedArray12815.115 ns0.3600 ns0.4920 ns
InitializedArray25627.904 ns0.5737 ns0.4791 ns
UninitializedArray25628.460 ns0.5196 ns0.4339 ns
InitializedArray38439.940 ns0.4010 ns0.3760 ns
UninitializedArray38440.470 ns0.4060 ns0.3800 ns
InitializedArray51254.298 ns1.1052 ns1.2728 ns
UninitializedArray51248.011 ns0.3103 ns0.2903 ns
InitializedArray1024106.870 ns1.9866 ns1.8583 ns
UninitializedArray102461.105 ns0.9496 ns0.8418 ns

String

MethodSizeMeanErrorStdDev
InitializedArray02.112 ns0.0221 ns0.0207 ns
UninitializedArray02.872 ns0.0837 ns0.0783 ns
InitializedArray12.313 ns0.0238 ns0.0211 ns
UninitializedArray13.040 ns0.0464 ns0.0434 ns
InitializedArray23.111 ns0.0349 ns0.0310 ns
UninitializedArray23.198 ns0.0262 ns0.0205 ns
InitializedArray43.244 ns0.0572 ns0.0507 ns
UninitializedArray45.894 ns0.1662 ns0.4320 ns
InitializedArray84.159 ns0.1304 ns0.1281 ns
UninitializedArray85.083 ns0.1007 ns0.0942 ns
InitializedArray165.527 ns0.0780 ns0.0730 ns
UninitializedArray167.092 ns0.1682 ns0.1573 ns
InitializedArray329.007 ns0.2214 ns0.2175 ns
UninitializedArray329.496 ns0.2325 ns0.2585 ns
InitializedArray6415.900 ns0.2476 ns0.2316 ns
UninitializedArray6416.595 ns0.1774 ns0.1660 ns
InitializedArray12828.697 ns0.4198 ns0.3926 ns
UninitializedArray12828.969 ns0.3815 ns0.3569 ns
InitializedArray38481.260 ns1.2850 ns1.1400 ns
UninitializedArray38483.200 ns0.5460 ns0.4260 ns
InitializedArray25656.482 ns1.0334 ns1.4821 ns
UninitializedArray25658.792 ns0.5612 ns0.5249 ns
InitializedArray512113.618 ns1.7741 ns2.3068 ns
UninitializedArray512109.537 ns0.8621 ns0.6730 ns

Vector128

MethodSizeMeanErrorStdDev
InitializedArray02.916 ns0.0667 ns0.0557 ns
UninitializedArray02.647 ns0.0556 ns0.0493 ns
InitializedArray12.923 ns0.0392 ns0.0367 ns
UninitializedArray13.734 ns0.0626 ns0.0555 ns
InitializedArray23.514 ns0.0365 ns0.0342 ns
UninitializedArray23.728 ns0.0767 ns0.0717 ns
InitializedArray44.584 ns0.0983 ns0.0872 ns
UninitializedArray44.669 ns0.0963 ns0.0900 ns
InitializedArray86.826 ns0.1845 ns0.5411 ns
UninitializedArray87.205 ns0.1767 ns0.1566 ns
InitializedArray1610.198 ns0.2494 ns0.5579 ns
UninitializedArray1610.371 ns0.1555 ns0.1379 ns
InitializedArray3217.732 ns0.2089 ns0.1852 ns
UninitializedArray3217.850 ns0.2649 ns0.2477 ns
InitializedArray6433.419 ns0.4831 ns0.4519 ns
UninitializedArray6434.592 ns0.6800 ns0.6361 ns
InitializedArray12864.925 ns1.3368 ns1.3129 ns
UninitializedArray12856.595 ns0.5739 ns0.5368 ns
InitializedArray256131.832 ns1.9061 ns1.6897 ns
UninitializedArray25677.677 ns1.1879 ns1.0530 ns
InitializedArray384198.300 ns3.8900 ns4.6200 ns
UninitializedArray38495.340 ns0.5700 ns0.4450 ns
InitializedArray512246.528 ns4.1630 ns3.6904 ns
UninitializedArray512115.572 ns1.0041 ns0.8901 ns
InitializedArray1024428.161 ns6.6520 ns6.2223 ns
UninitializedArray1024186.133 ns2.3016 ns2.1529 ns

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Will close this for now and open a discussion thread instead until we can come to a decision, one way or the other.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Logged #47198

@ghostghost locked as resolved and limited conversation to collaborators Feb 18, 2021
@tannergooding
tannergooding deleted the uninit-array branch July 1, 2025 14:39
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@tannergooding@GrabYourPitchforks@jkotas@stephentoub@En3Tho