Arm64: opportunistically light up LSE atomics for NativeAOT - #132358
Arm64: opportunistically light up LSE atomics for NativeAOT#132358EgorBo wants to merge 6 commits into
Conversation
NativeAOT's arm64 baseline is armv8-a, so `Interlocked` operations were stuck emitting `ldaxr`/`stlxr` retry loops even though virtually all real hardware implements the Armv8.1 LSE atomics. The runtime JIT already picks the single instruction forms because it knows the exact CPU it is running on; AOT could not, because the check has to happen at runtime. Rather than teach codegen to branch on a CPU feature word, the check is expressed in managed code. A new internal `System.Runtime.Intrinsics.Arm.Lse` hardware intrinsic class exposes the five atomic primitives, and the `Interlocked` bodies call them behind `if (Lse.IsSupported)`. On NativeAOT `IsSupported` already lowers to a cached CPU feature query, so the existing opportunistic light-up machinery does all the work and no new JIT/EE API is needed. To make that check observable, the importer declines to intrinsify `Interlocked` exactly when the atomics are opportunistically available but not part of the baseline - the "Dynamic" state, which in practice only NativeAOT ever reaches. The call is then imported normally, the body is inlined, and the check lights up. Recursion is bounded by `mustExpand`: the fallback arm calls the same API from within its own body, and recursive intrinsic calls must expand, so it always bottoms out in the ldaxr/stlxr sequence. Every other configuration keeps the state it had before - baseline LSE stays baseline, no-LSE stays no-LSE - so this is a strict no-op for the runtime JIT, R2R, and NativeAOT on Windows/macOS. SuperPMI asmdiffs over the linux-arm64 benchmarks.run collection report zero diffs across 142,660 contexts. Covers CompareExchange, Exchange, ExchangeAdd (Add/Increment/Decrement), And, and Or, for both int and long. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec0dae32-314b-42fd-a971-95921ea8ebb4
- Add the "outlined on AOT" comment to the other two Interlocked files; it was only in Interlocked.CoreCLR.cs. - Promote the two base type checks in the Lse importer to noway_assert. These APIs are generic, so an unsupported type argument would otherwise silently produce an atomic access of the wrong width in a release JIT; now it fails the compilation instead. Verified: Lse.LoadAdd<float> fails importation rather than emitting a bogus ldaddal, and Lse.Swap<DayOfWeek> correctly emits a 32-bit swpal (the JIT sees enums as their underlying primitive). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec0dae32-314b-42fd-a971-95921ea8ebb4
Replace the noway_asserts guarding the Lse type argument with the standard impUnsupportedNamedIntrinsic path, so an unsupported instantiation compiles into a throw helper call rather than killing the compilation. The check is made before any argument is popped, and asks for a throwing expansion unconditionally because the managed bodies are recursive stubs - returning nullptr would fall back to a real call that recurses forever. Verified on arm64: Lse.LoadAdd<float> now emits a call to CORINFO_HELP_THROW_PLATFORM_NOT_SUPPORTED (AOT remaps TypeNotSupported to PNSE) instead of tripping an assert, and Lse.Swap<DayOfWeek> still emits swpal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec0dae32-314b-42fd-a971-95921ea8ebb4
|
Azure Pipelines: Successfully started running 8 pipeline(s). 8 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch |
Validated the real ILC path for the first time (x64-hosted ILC, --targetarch
arm64 --targetos linux) rather than the JitStressAtomicsLightUp knob, and found
that Exchange(ref int), Exchange(ref long) and CompareExchange(ref int) were
left as out-of-line calls:
bl System.Threading.Interlocked:Exchange(byref,int):int
Those three are the only ones of the group that were not already marked
AggressiveInlining. It never mattered before because they were always
intrinsified at the call site, but now that the JIT declines to intrinsify them
the inliner has to carry them, and its cost model rejects the larger body.
With the attribute they inline as intended on linux-arm64:
ldr w2, [x2] ; cached CPU features
tbnz w2, dotnet#6, LSE ; bit 6 == Arm64IntrinsicConstants.Atomics
ldaxr/stlxr ... ; armv8-a fallback
LSE: swpal w0, w3, [x1]
win-arm64 is unchanged (LSE is in its baseline): no feature check and no
ldaxr/stlxr anywhere, just casal/swpal/ldaddal/ldsetal/ldclral.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ec0dae32-314b-42fd-a971-95921ea8ebb4
There was a problem hiding this comment.
Pull request overview
This PR adds an opportunistic Arm64 LSE (Armv8.1 atomics) fast path for System.Threading.Interlocked by introducing an internal System.Runtime.Intrinsics.Arm.Lse intrinsic and updating CoreCLR JIT lowering/LSRA/codegen to correctly select LSE vs ldaxr/stlxr expansion per-node (including a “dynamic/light-up” mode used by NativeAOT).
Changes:
- Add internal
System.Runtime.Intrinsics.Arm.Lseintrinsic API and wire it into the CoreLib build and the instruction-set metadata plumbing. - Update
Interlockedimplementations (CoreLib/CoreCLR/NativeAOT) to useif (Lse.IsSupported)to select LSE atomics, falling back to existing loops/intrinsics otherwise. - Teach the Arm64 JIT to represent/route LSE atomics via
GTF_ATOMIC_LSEandAtomicsImplselection, ensuring correct containment decisions, LSRA behavior, CSE behavior, and codegen emission.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/libraries/System.Private.CoreLib/src/System/Threading/Interlocked.cs | Add Arm64 Lse.IsSupported fast paths for And/Or to use LSE atomics when available. |
| src/libraries/System.Private.CoreLib/src/System/Runtime/Intrinsics/Arm/Lse.cs | New internal intrinsic surface for Arm64 LSE atomics used by Interlocked. |
| src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems | Include the new Lse.cs in the CoreLib build. |
| src/coreclr/tools/Common/JitInterface/ThunkGenerator/InstructionSetDesc.txt | Map ARM64 Atomics (“lse”) to managed type name Lse. |
| src/coreclr/tools/Common/JitInterface/CorInfoInstructionSet.cs | Teach tooling to recognize Lse as the ARM64 Atomics instruction set and enumerate the type. |
| src/coreclr/System.Private.CoreLib/src/System/Threading/Interlocked.CoreCLR.cs | Add Lse.IsSupported fast paths for Exchange/CompareExchange/ExchangeAdd on Arm64. |
| src/coreclr/nativeaot/System.Private.CoreLib/src/System/Threading/Interlocked.cs | Add Lse.IsSupported fast paths for NativeAOT Interlocked intrinsics on Arm64. |
| src/coreclr/jit/lsraarm64.cpp | Adjust internal-reg/lifetime decisions based on whether a node will lower to LSE vs LL/SC. |
| src/coreclr/jit/lowerarmarch.cpp | Prevent immediate containment for atomic ops when the chosen expansion is LSE. |
| src/coreclr/jit/jitconfigvalues.h | Add JitStressAtomicsLightUp to force exercising the managed light-up path under the runtime JIT. |
| src/coreclr/jit/importercalls.cpp | Add logic to selectively not expand certain Interlocked intrinsics to enable managed light-up in “dynamic” mode. |
| src/coreclr/jit/hwintrinsiclistarm64.h | Define new Atomics (LSE) hardware intrinsic entries and instruction mappings. |
| src/coreclr/jit/hwintrinsicarm64.cpp | Recognize Lse ISA, import Atomics intrinsics to existing atomic nodes marked GTF_ATOMIC_LSE. |
| src/coreclr/jit/hwintrinsic.cpp | Register the Atomics intrinsic range so it’s discoverable as an ISA group. |
| src/coreclr/jit/gentree.h | Add GTF_ATOMIC_LSE flag to distinguish LSE atomic nodes. |
| src/coreclr/jit/gentree.cpp | Prevent CSE merging of atomic nodes that differ only by GTF_ATOMIC_LSE. |
| src/coreclr/jit/compiler.h | Introduce AtomicsImpl and compGetAtomicsImplForNode to select expansion strategy per node. |
| src/coreclr/jit/compiler.cpp | Implement cached compGetAtomicsImpl() with dynamic/light-up selection and stress support. |
| src/coreclr/jit/codegenarm64.cpp | Emit LSE single-instruction atomics when requested; otherwise emit LL/SC sequences. |
| /// Unlike the Interlocked APIs these never fall back to an ldaxr/stlxr retry loop, so they may | ||
| /// only be called when <see cref="IsSupported"/> is true. The type argument must be an integer | ||
| /// (or an enum over one) no wider than a pointer; sizes of 1 and 2 bytes are only supported by | ||
| /// <see cref="CompareAndSwap{T}"/> and <see cref="Swap{T}"/>. Anything else fails to compile. |
| #if defined(TARGET_ARM64) | ||
| // Stress the opportunistic light-up of the Armv8.1 LSE atomics: pretend they are not part of the | ||
| // baseline instruction set even when they are, so that the managed `if (Lse.IsSupported)` checks in | ||
| // the Interlocked APIs are used. This state occurs naturally for NativeAOT, but never for the | ||
| // runtime JIT, so this is the only way to cover it outside of AOT compilation. | ||
| CONFIG_INTEGER(JitStressAtomicsLightUp, "JitStressAtomicsLightUp", 0) | ||
| #endif // TARGET_ARM64 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/libraries/System.Private.CoreLib/src/System/Runtime/Intrinsics/Arm/Lse.cs:21
- The remark "Anything else fails to compile" is misleading: unsupported type arguments are rejected by the JIT/importer by throwing, rather than being a C# compile-time error. Please reword this to describe the runtime behavior accurately.
/// Unlike the Interlocked APIs these never fall back to an ldaxr/stlxr retry loop, so they may
/// only be called when <see cref="IsSupported"/> is true. The type argument must be an integer
/// (or an enum over one) no wider than a pointer; sizes of 1 and 2 bytes are only supported by
/// <see cref="CompareAndSwap{T}"/> and <see cref="Swap{T}"/>. Anything else fails to compile.
adrp x2, [HIGH RELOC #0x422388] // static handle
add x2, x2, [LOW RELOC #0x422388]
ldr w3, [x2]
tbnz w3, #6, IG09ISA check is not cheap on arm-aot 😞 (still should be a massive win under high contention) |
crossgen-corelib for maccatalyst-arm64 failed:
Code generation failed for method
'[S.P.CoreLib]System.Runtime.Intrinsics.Arm.Lse.CompareAndSwap<T>(!!0&,!!0,!!0)'
---> System.InvalidCastException: Unable to cast object of type
'Internal.TypeSystem.SignatureMethodVariable' to type 'Internal.TypeSystem.DefType'
R2R compiles the uninstantiated definition of a generic method. The usual
recursive intrinsic stub then presents the importer with a self-call whose type
argument is still a signature method variable, and trying to intrinsify that
falls over. Apple arm64 is where it shows up because its baseline is apple-m1,
so Lse.IsSupported folds to true and the call is live; everywhere else the ISA
is not in the baseline, IsSupported folds to false and the body is dead code.
The recursive stub is a convention for non-generic intrinsics and buys nothing
here, so throw instead. All call sites are expanded at the call site, and a
caller that ignores the contract now gets an exception rather than unbounded
recursion.
Verified by reproducing the exact crossgen2 command line locally (composite,
mibc, macho, --targetos:maccatalyst) before and after. NativeAOT linux-arm64
light-up is unchanged (tbnz dotnet#6 plus swpal/casal/ldaddal/ldsetal/ldclral, no
out-of-line calls), maccatalyst still emits baseline LSE with no feature checks,
SPMI asmdiffs remain at 0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ec0dae32-314b-42fd-a971-95921ea8ebb4
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/libraries/System.Private.CoreLib/src/System/Runtime/Intrinsics/Arm/Lse.cs:21
- The remarks say unsupported type arguments/sizes “fail to compile”, but the JIT path for these intrinsics treats unsupported types as a runtime throw (see impUnsupportedNamedIntrinsic(CORINFO_HELP_THROW_TYPE_NOT_SUPPORTED) in the special-import path). The docs should match the actual behavior so callers don’t assume compile-time failure.
/// Unlike the Interlocked APIs these never fall back to an ldaxr/stlxr retry loop, so they may
/// only be called when <see cref="IsSupported"/> is true. The type argument must be an integer
/// (or an enum over one) no wider than a pointer; sizes of 1 and 2 bytes are only supported by
/// <see cref="CompareAndSwap{T}"/> and <see cref="Swap{T}"/>. Anything else fails to compile.
| public static new bool IsSupported { get => IsSupported; } | ||
|
|
||
| // Note these are deliberately not the usual recursive intrinsic stubs. They are generic, and | ||
| // R2R compiles the uninstantiated definition of a generic method, which would then try to |
There was a problem hiding this comment.
Uninstantiated method definitions cannot be ever compiled. This comment does not make sense.
There was a problem hiding this comment.
All HW intrinsics aren't generic, I wonder if I should make LSE non-generic too, but then it will introduce a big type-switch (with unsafe bitcast to handle enums) 🤔 or keep it as is.
Reverts the previous commit's workaround and fixes the actual problem, which jkotas correctly pointed out my comment had described nonsensically: an uninstantiated generic method definition is never supposed to be compiled. ReadyToRunHardwareIntrinsicRootProvider roots every method on an intrinsic class whose ISA is in the supported set. AddCompilationRoot canonicalizes with GetCanonMethodTarget(CanonicalFormKind.Specific), which only maps reference types to __Canon - a SignatureMethodVariable passes through unchanged, so the typical definition gets rooted and queued, and compilation then fails as soon as the body resolves a token mentioning !!0: Unable to cast object of type 'Internal.TypeSystem.SignatureMethodVariable' to type 'Internal.TypeSystem.DefType' No existing hardware intrinsic class has generic methods, so this had never been exercised. Skip them; instantiations that are actually used are rooted through their callers. This lets Lse.cs go back to the conventional recursive intrinsic stubs. Verified: composite crossgen of CoreLib with the CI command line (mibc, --embed-pgo-data) now succeeds for arm64 on maccatalyst, osx, linux and windows, with the recursive stubs restored. NativeAOT linux-arm64 light-up is unchanged (tbnz dotnet#6 plus swpal/casal/ldaddal/ldsetal/ldclral, no out-of-line calls), an unsupported type argument still compiles to a throw helper rather than recursing, and SPMI asmdiffs remain at 0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec0dae32-314b-42fd-a971-95921ea8ebb4
|
/azp list |
This comment was marked as resolved.
This comment was marked as resolved.
|
/azp run runtime-nativeaot-outerloop |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
|
Does it make sense to share the same intrinsic definitions for ARM64, RISCV and Loongarch instead? |
Those are not exactly the same isa, are they? LSE has (and LSE2) have quite a few things we might expose in the future, i don't think we can share anything (although, some of the changes in JIT I made are shared). |
|
Azure Pipelines: Successfully started running 8 pipeline(s). 8 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/coreclr/jit/jitconfigvalues.h:876
- This switch is described as the only non-AOT coverage for the new dynamic light-up path, but no test enables it. The importer deferral,
GTF_ATOMIC_LSEpropagation, LSRA, and both used/unused-result codegen paths can therefore regress without CI detecting it. Add an arm64 test configured withDOTNET_JitStressAtomicsLightUp=1that exercises the int/long Add, Exchange, CompareExchange, And, and Or forms, including discarded results.
CONFIG_INTEGER(JitStressAtomicsLightUp, "JitStressAtomicsLightUp", 0)
src/coreclr/jit/importercalls.cpp:3316
- This return-type-only filter also accepts the internal
CompareExchange(int*, int, int)overload, but that overload has noLse.IsSupportedbranch. In the Dynamic NativeAOT case it is therefore left as a regular call (or inlined only to the LL/SC recursive expansion), adding overhead without enabling LSE. Check that the first parameter is a managed byref, or add an LSE-capable pointer path, so this overload keeps its previous direct intrinsic expansion.
var_types retType = JITtype2varType(sig->retType);
if (!varTypeIsIntegral(retType) || (genTypeSize(retType) < 4) || (genTypeSize(retType) > TARGET_POINTER_SIZE))
Closes #129951
.NET 12.0
Using an internal
Lse.csintrinsic API with IsSupported and 5 generic APIs (they emit a call to NotSupportedException when called for unsupported T).NativeAOT codegen, linux-arm64 (LSE is not in the baseline there, so it is a runtime check)