You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[NativeAOT] 230 kB size regression in Hello World from Enum changes #79437
There's no clear culprit, it's a death by thousand papercuts. There are some odd things in the diff, like why do we need so many new delegates, arraysorthelpers, System.Half, System.Int128, etc.
changed the title [-][NativeAOT] 130 kB size regression in Hello World from Enum changes[/-][+][NativeAOT] 230 kB size regression in Hello World from Enum changes[/+]on Dec 9, 2022
If it turns out the size increase in both NativeAOT and mono AOT are due to (2) and/or (3), I can temporarily back those out until we come up with a solution.
There are some odd things in the diff, like why do we need so many new delegates, arraysorthelpers, System.Half, System.Int128, etc.
Yeah, I don't know how the enum changes would have impacted that, though they obviously did.
Each Array.Sort instantiation costs about 10kB of AOT binary footprint. The change introduced 13 Array.Sort instantiations, so that explains more than half of the regression.
System.Half, System.Int128,
The numeric interface methods have less than ideal behavior with trimming. If a numeric interface method is used on one type, it gets preserved on all types that implement. It explains the new methods on System.Half, System.Int128.
Each Array.Sort instantiation costs about 10kB of AOT binary footprint. The change introduced 13 Array.Sort instantiations, so that explains more than half of the regression.
I see. Well native aot doesn't support 5 of those (right?). We could trivially ifdef out the types not expressible in C#. That'd eliminate 40% of that. We could pay the one-time-per-enum costs to use the non-generic sort to address the rest, at least for aot?
The numeric interface methods have less than ideal behavior with trimming. If a numeric interface method is used on one type, it gets preserved on all types that implement. It explains the new methods on System.Half, System.Int128.
I can change the implementations to not use the generic interfaces and just special-case everything to each of the underlying types, but that's very far from ideal, and these instantiation will all come right back the moment someone else in the app uses them. What would you recommend?
I think there were some significant improvements in trimming added to ILLink recently. @jtschuster would know more about whether they would work in these cases.
If we can improve trimming such that these interfaces are removable when unused I think that's probably the right direction.
@MichalStrehovsky, would it be relatively straightforward for you to determine how much of this increase is due to:
It's not immediately obvious from the logs in the top post and actually trying it would involve manually backing out pieces of the change and recompiling. Not keen on doing that.
System.Half, System.Int128,
The numeric interface methods have less than ideal behavior with trimming. If a numeric interface method is used on one type, it gets preserved on all types that implement. It explains the new methods on System.Half, System.Int128.
I started drilling into this part - the numeric interfaces add about 29 kB of the total cost. I think it's doable to get rid of that in the compiler. The problem is patterns like this:
This brings a boxed Half into the system. RyuJIT can already eliminate the entire branch (because we compile instantiated code), but we run the compilation in two phases (the second one is RyuJIT) and the first phase ends up unnecessarily rooting a boxed Half. There are two ways to approach this: either remove unreachable branches in the first phase (we can do it without the whole program view we're building because this scans instantiated code too), or make it so that we don't pass the rooted Half to the second phase. I already have a working prototype for the former, but looking into the latter as well because it might be more generally applicable.
That will get rid of the 29 kB but there's still plenty left.
I think we could get rid of a good chunk if we could do something about the sorting. We now sort the enums with the object-based Array.Sort, but that means it brings code to compare DateTime, decimal, strings and a bunch of other stuff. Commenting out the Array.Sort saves another 24 kB. If we could save that, we could close this issue.
I think we could get rid of a good chunk if we could do something about the sorting
What if we just used a simple but O(n^2) sort like insertion sort, banking on enum's being relatively small in size in general? If we went back to it being generic, such that there were still eight copies, but it was small, I wonder if that would suffice. We could try calling
We now sort the enums with the object-based Array.Sort, but that means it brings code to compare DateTime, decimal, strings and a bunch of other stuff.
We used ulong-based sort before. How did ulong-based sort avoided bringing in all this stuff?
We now sort the enums with the object-based Array.Sort, but that means it brings code to compare DateTime, decimal, strings and a bunch of other stuff.
We used ulong-based sort before. How did ulong-based sort avoided bringing in all this stuff?
Here's the relevant chunk from the WhyDgml output:
The default comparers are special cased by the compiler. If we're in unshared code, we know exactly which comparer to use. If we're in shared generic code, ObjectComparer it is and that one uses IComparable.CompareTo, bringing comparison functionality for everything that was ever allocated or boxed in the program.
I think we could get rid of a good chunk if we could do something about the sorting
What if we just used a simple but O(n^2) sort like insertion sort, banking on enum's being relatively small in size in general? If we went back to it being generic, such that there were still eight copies, but it was small, I wonder if that would suffice. We could try calling
What's "relatively small" in this context? For example, just searching some of the C# projects I've recently touched on my machine, I have an enum of wasm opcodes that's got about 250 values in it (and would have more if I added further extensions). I've got a D3DFORMAT enum with upwards of 50 entries by my count. I also see a bunch of mid-to-large-sized enums in libraries like SDL2, mojoshader, sharpfont, etc. How badly would this stuff degrade for a large enum? Is there a path for end users to work around the bad sort? I don't have a good intuition for how bad insertion sort would be for an enum with 250 values in it, but I suspect regular production software has enums big enough to get into the Trouble Zone for O(N^2) unless O is very fast or this code path is only hit for obscure scenarios.
I have an enum of wasm opcodes that's got about 250 values in it ... How badly would this stuff degrade for a large enum?
It would depend on how close to sorted the values were. Here, for 250 values, you can see cases where the data is already perfectly sorted (Mode 0), where the first and last elements are swapped (Mode 1), and where the elements are entirely reversed (Mode 2). If the data is already sorted, insertion sort is typically really fast. If it's mostly sorted, it's reasonably on par. If it's not at all sorted, it's an order of magnitude slower.
We now sort the enums with the object-based Array.Sort, but that means it brings code to compare DateTime, decimal, strings and a bunch of other stuff. Commenting out the Array.Sort saves another 24 kB. If we could save that, we could close this issue.
We now sort the enums with the object-based Array.Sort, but that means it brings code to compare DateTime, decimal, strings and a bunch of other stuff. Commenting out the Array.Sort saves another 24 kB. If we could save that, we could close this issue.
Activity
[-][NativeAOT] 130 kB size regression in Hello World from Enum changes[/-][+][NativeAOT] 230 kB size regression in Hello World from Enum changes[/+]stephentoub commentedon Dec 9, 2022
@MichalStrehovsky, would it be relatively straightforward for you to determine how much of this increase is due to:
EnumInfo<TUnderlyingType>rather than storing everything as a longruntime/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/DefaultInterpolatedStringHandler.cs
Lines 309 to 319 in add75d0
runtime/src/libraries/System.Private.CoreLib/src/System/Enum.cs
Line 26 in add75d0
?
If it turns out the size increase in both NativeAOT and mono AOT are due to (2) and/or (3), I can temporarily back those out until we come up with a solution.
Yeah, I don't know how the enum changes would have impacted that, though they obviously did.
jkotas commentedon Dec 9, 2022
Each Array.Sort instantiation costs about 10kB of AOT binary footprint. The change introduced 13 Array.Sort instantiations, so that explains more than half of the regression.
The numeric interface methods have less than ideal behavior with trimming. If a numeric interface method is used on one type, it gets preserved on all types that implement. It explains the new methods on System.Half, System.Int128.
stephentoub commentedon Dec 9, 2022
I see. Well native aot doesn't support 5 of those (right?). We could trivially ifdef out the types not expressible in C#. That'd eliminate 40% of that. We could pay the one-time-per-enum costs to use the non-generic sort to address the rest, at least for aot?
stephentoub commentedon Dec 9, 2022
I can change the implementations to not use the generic interfaces and just special-case everything to each of the underlying types, but that's very far from ideal, and these instantiation will all come right back the moment someone else in the app uses them. What would you recommend?
agocke commentedon Dec 9, 2022
I think there were some significant improvements in trimming added to ILLink recently. @jtschuster would know more about whether they would work in these cases.
If we can improve trimming such that these interfaces are removable when unused I think that's probably the right direction.
marek-safar commentedon Dec 10, 2022
@agocke the linker is not used for code trimming for nativeaot
MichalStrehovsky commentedon Dec 12, 2022
It's not immediately obvious from the logs in the top post and actually trying it would involve manually backing out pieces of the change and recompiling. Not keen on doing that.
I started drilling into this part - the numeric interfaces add about 29 kB of the total cost. I think it's doable to get rid of that in the compiler. The problem is patterns like this:
runtime/src/libraries/System.Private.CoreLib/src/System/Byte.cs
Lines 1088 to 1093 in b1a2080
This brings a boxed
Halfinto the system. RyuJIT can already eliminate the entire branch (because we compile instantiated code), but we run the compilation in two phases (the second one is RyuJIT) and the first phase ends up unnecessarily rooting a boxedHalf. There are two ways to approach this: either remove unreachable branches in the first phase (we can do it without the whole program view we're building because this scans instantiated code too), or make it so that we don't pass the rootedHalfto the second phase. I already have a working prototype for the former, but looking into the latter as well because it might be more generally applicable.That will get rid of the 29 kB but there's still plenty left.
stephentoub commentedon Dec 12, 2022
In case you change your mind:
(2): https://github.com/stephentoub/runtime/tree/commentoutisenum
(3): https://github.com/stephentoub/runtime/tree/commentoutenumspanformattable
(2+3): https://github.com/stephentoub/runtime/tree/commentoutboth
agocke commentedon Dec 12, 2022
Right, we would implement the same thing @jtschuster did for Native AOT, or more advanced if necessary.
However, that typeof pattern is tricky. Branch elimination was not what I had in mind.
MichalStrehovsky commentedon Dec 13, 2022
Thanks, I'll take a look at that tomorrow.
In the meantime, #79594 gets rid of the costs associated with Half/Int128/UInt128 (29 kB) and a little bit extra on top.
MichalStrehovsky commentedon Dec 14, 2022
With:
MethodTables #79594We're down to a ~50 kB regression (2%).
Here's the MAP files (diffable with windiff):
I think we could get rid of a good chunk if we could do something about the sorting. We now sort the enums with the object-based Array.Sort, but that means it brings code to compare DateTime, decimal, strings and a bunch of other stuff. Commenting out the Array.Sort saves another 24 kB. If we could save that, we could close this issue.
stephentoub commentedon Dec 14, 2022
What if we just used a simple but O(n^2) sort like insertion sort, banking on enum's being relatively small in size in general? If we went back to it being generic, such that there were still eight copies, but it was small, I wonder if that would suffice. We could try calling
runtime/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/ArraySortHelper.cs
Line 1034 in c635ae2
jkotas commentedon Dec 14, 2022
We used ulong-based sort before. How did ulong-based sort avoided bringing in all this stuff?
MichalStrehovsky commentedon Dec 14, 2022
Here's the relevant chunk from the WhyDgml output:
The default comparers are special cased by the compiler. If we're in unshared code, we know exactly which comparer to use. If we're in shared generic code,
ObjectComparerit is and that one usesIComparable.CompareTo, bringing comparison functionality for everything that was ever allocated or boxed in the program.kg commentedon Dec 20, 2022
What's "relatively small" in this context? For example, just searching some of the C# projects I've recently touched on my machine, I have an
enumof wasm opcodes that's got about 250 values in it (and would have more if I added further extensions). I've got a D3DFORMAT enum with upwards of 50 entries by my count. I also see a bunch of mid-to-large-sized enums in libraries like SDL2, mojoshader, sharpfont, etc. How badly would this stuff degrade for a large enum? Is there a path for end users to work around the bad sort? I don't have a good intuition for how bad insertion sort would be for an enum with 250 values in it, but I suspect regular production software has enums big enough to get into the Trouble Zone for O(N^2) unless O is very fast or this code path is only hit for obscure scenarios.stephentoub commentedon Dec 20, 2022
It would depend on how close to sorted the values were. Here, for 250 values, you can see cases where the data is already perfectly sorted (Mode 0), where the first and last elements are swapped (Mode 1), and where the elements are entirely reversed (Mode 2). If the data is already sorted, insertion sort is typically really fast. If it's mostly sorted, it's reasonably on par. If it's not at all sorted, it's an order of magnitude slower.
Benchmark
But it sounds like my question/thought is mostly moot anyway as it seems Michal has a solution that fixes the problem with a custom comparer.
stephentoub commentedon Jan 4, 2023
@MichalStrehovsky, is there still more to do here, or does #79845 address this?
MichalStrehovsky commentedon Jan 5, 2023
#79594 mentioned in the calculations above recovers 30 kB of the regression but didn't merge yet.
MichalStrehovsky commentedon Jan 11, 2023
All of the pull requests have been merged.