Repository navigation
PlaintextMVC benchmark is slow on arm64 #60166
Description
Plaintext-PlaintextMVC benchmark should benefit a lot from PGO (namely, from guarded devirtualizations and inlining) and it does benefit from it on all x64 platforms (Linux, Windows, Intel, Amd, etc) - up to +40% more RPS. Unfortunately, it's not the case for arm64 where there is no difference between DynamicPGO and Default. Moreover, the benchmark is 7-8x times slower on arm64 in comparison with x64-dynamicpgo (while I'd expect it to be 1.5-2x slower only).
It looks to me that on arm64 it's bound to JIT_New:

while on x64 it looks like this:

Namely, this call-site ("drilled") (https://github.com/dotnet/aspnetcore/blob/a0b950fc51c43289ab3c6dbea15926d32f3556cc/src/Mvc/Mvc.Core/src/Routing/ControllerRequestDelegateFactory.cs#L68-L101):
Arm64:

Flamegraph for arm64 (two JIT_New are highlighted) for the first th:

Does it ring a bell to anyone (e.g. JIT_NewS_MP_FastPortable is not used, some gc feature is not implemented for arm64, some allocation tracker/profiler is enabled, etc.)?
/cc @dotnet/jit-contrib @Maoni0 @jkotas @davidwrighton
I can re-run the benchmark with any modifications in jit/vm/gc you suggest.
Steps to get the native traces:
Arm64-Linux:
crank --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/plaintext.benchmarks.yml --scenario mvc --profile aspnet-citrine-arm --application.collect true --application.collectStartup false --application.options.collectCounters false --application.dotnetTrace false --application.framework net6.0 --application.environmentVariables DOTNET_TieredPGO=1 --application.environmentVariables DOTNET_TC_QuickJitForLoops=1 --application.environmentVariables DOTNET_ReadyToRun=0
x64-Linux:
crank --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/plaintext.benchmarks.yml --scenario mvc --profile aspnet-citrine-lin --application.collect true --application.collectStartup false --application.options.collectCounters false --application.dotnetTrace false --application.framework net6.0 --application.environmentVariables DOTNET_TieredPGO=1 --application.environmentVariables DOTNET_TC_QuickJitForLoops=1 --application.environmentVariables DOTNET_ReadyToRun=0
Powerbi link: https://aka.ms/aspnet/benchmarks (open "PGO" page there)



Activity
EgorBo commentedon Oct 8, 2021
A similar benchmark "Plaintex-Plaintext" looks much better:

stephentoub commentedon Oct 8, 2021
Do we know that is actually slower as opposed to maybe something else is causing that lambda to be invoked many more times?
EgorBo commentedon Oct 8, 2021
Not sure, but I'd expect codepaths to be exactly the same, btw, here is a comparison for all benchmarks we run PGO for:


Exactly the same anomalies in JsonMVC, Foruntes and FortunesEF where arm64 is 5-7x times slower.
EgorBo commentedon Oct 8, 2021
^ Most of the benchmarks get a nice boost from DynamicPGO (to be more precise - from FullPGO where we disable R2R and enable QuickJitForLoops) on x64, but I don't see the same boost on arm64 for Fortunes, FortunesEF, JsonMVC and PlaintextMVC - and I suspect that it's because of "bound to JIT_New".
EgorBo commentedon Oct 8, 2021
I don't know the exact arm64 hardware we have, but NodeJS benchmarks show around 50-100% difference between arm64 and x64 so I guess we can expect the same ratio on .NET.

JsonMvc and Plaintext benchmarks on NodeJS:

It'd be nice if we could run Actix (Rust) benchmarks on arm64 too - it should show a more precise ratio due to the fact it uses LLVM which is heavily optimized for both x64 and arm64.

Currently, we only run Actix for x64.
cc @sebastienros
sebastienros commentedon Oct 8, 2021
AFAIR the Docker image defined in TE's repo for Actix is not working for ARM64. Will try again.
EgorBo commentedon Oct 8, 2021
It's not necessary to run Actix but anything fast and other than .NET on both x64 and arm64 just to feel the difference
jkotas commentedon Oct 8, 2021
Could you please take a look at the GC info tab in perf-view that shows the GC stats, and share it here for x64 vs. arm64?
EgorBo commentedon Oct 8, 2021
@jkotas while I'm re-opening the traces (it takes quite some time on my PC) here are the perf counters (

--application.options.collectCounters true):sebastienros commentedon Oct 8, 2021
NB: these are markdown formatted and also correctly spaced so you can either paste them as-is or wrap them in triple back ticks.
Stating the obvious:
Some x64 numbers are multiples of arm64 because RPS is just higher, e.g. Allocations, TP, so nothing weird here.
But the GC is abnormally high on arm64: 66% cpu and 59 gen 0 collections per second and the Gen 0 size significantly higher too.
Also can you use
--chart, this will show if the max GC (66%) is just a small period of time, of if it's for the whole run.jkotas commentedon Oct 8, 2021
I think the GC is clearly part of the problem. Gen0 GC are firing 4x more frequently per second and throughput is 8x lower. It means that the GC rate is effectively 30x higher. @dotnet/gc should take a look.
37 remaining items
kunalspathak commentedon Jan 3, 2022
If I am reading the traces, it seems that GC pause time on arm64 is 5X than x64.
Both scenarios were ran using flags
--application.dotnetTrace true --application.dotnetTraceProviders gc-collect --application.memoryLimitInBytes 17179869184 --application.cpuSet 0-7.x64:
arm64: (in addition to above flags, also added
-application.environmentVariables DOTNET_GCgen0size=1E00000)@Maoni0 - can you suggest any further steps to track this down?
Maoni0 commentedon Jan 3, 2022
does it also collect more? what's the survived bytes for these GCs? you could also share the traces.
kunalspathak commentedon Jan 3, 2022
I will email them to you.
kunalspathak commentedon Jan 3, 2022
Looking at the traces with @Maoni0, there are some unusual callstack present in StackWalker code that does more work in arm64 compared to x64:
arm64:
x64:
@mangod9 - Do you know what would make arm64 do more work than x64 in stack walking code?
mangod9 commentedon Jan 3, 2022
@AntonLapounov @janvorli in case they have seen this before. Could you please share traces with us?
kunalspathak commentedon Jan 5, 2022
Done over the email.
kunalspathak commentedon Jan 14, 2022
Update:
On @Maoni0 suggestion, we came up with a concentrated repro without the needing of threadpool/thread.sleep. With that we can see the slowness in allocation rate and regression in max pause time.
Below, the screenshots are for GCStats and call stack for both x64 vs. arm64. Some observations:
GC.Collect(). I am assuming this is because of overall speed difference. On x64, code is executing faster leading to more allocation of objects and hence more frequently GC is triggered. On arm64, the slowness lead to lower allocation and hence lower no. of GCs.plan_phaseandmark_phaseare the biggest contributor of the diff. Inplan_phase, even the simpleintrosort_looptakes longer to execute. My initial assumption was the difference in the code produced by LLVM (which we use to build linux x64/arm64). I tried to extract theintrosort_loop(and surrounding code) and checked the diff in godbolt, but I don't see anything that would stand out.mark_phase, I can see a clear difference inmark_object_simple1, but I don't see why it would be the case. May be there is huge difference in the count of data both platforms operate on?x64:
arm64:
jkotas commentedon Jan 15, 2022
One of the factors that influences the GC allocation rate is memory bandwidth. It may be a good idea to measure the memory bandwidth on the two machines: Write a simple micro-benchmark that clears 100MB buffer (no GC involved).
I do not think that this explanation makes sense. The benchmark you have shared allocates same amount on both arm64 and x64. I think that the different number of GCs between arm64 and x64 is caused by GC setting the allocation budgets differently. The allocation budgets are computed from the cache size, so the difference is most likely caused by the different (or incorrectly computed) cache sizes between arm64 and x64. There was a discussion about the buggy cache size computation earlier in this thread. Are you using any env. variables to compensate for it?
kunalspathak commentedon Jan 17, 2022
Yes. I am using the environment variable on arm64 to set the cache size. I am also setting the memory and CPU same for x64/arm64.
--application.environmentVariables DOTNET_GCgen0size=1E00000 --application.memoryLimitInBytes 17179869184 --application.cpuSet 0-7.AntonLapounov commentedon Feb 2, 2022
@kunalspathak How is it possible that
Max Gen 0 Sizeis only 24 bytes for your small repro and only 192 bytes for the benchmark on ARM64?kunalspathak commentedon Feb 2, 2022
Honestly I am not sure how those numbers are collected and reported. Currently I am working with @Maoni0 to gather traces for https://github.com/dotnet/performance/tree/main/src/benchmarks/gc/src/exec/GCPerfSim.
Maoni0 commentedon Feb 2, 2022
@AntonLapounov that's just the generation size after a GC, if there's no pinning, it could easily be 24 bytes (which is just a min object size)
EgorBo commentedon Jul 6, 2022
Closing as completed