arm64 ReadyToRun/NativeAOT: in a method over 1 MB, loads of the method's own read-only data (IF_LARGELDC / IF_LARGEADR) are emitted without relocations and read the wrong bytes

Aperta
#137,346 3 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
45/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
csharp
Ambito
backend, compilers

Direzione di ricerca

Start in src/coreclr/jit/emitarm64.cpp, in emitOutputLJ, and compare the IF_LARGELDC and IF_LARGEADR handling with emitOutputLoadLabel. Reproduce with the supplied generator and ReadyToRun or NativeAOT on arm64; inspect the emitted relocations for the constant loads and switch table. Done means the long-form loads address the method's read-only data correctly in both AOT paths.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

area-CodeGen-coreclr untriaged
Description

When crossgen2 (ReadyToRun) or ILC (NativeAOT) compiles a method whose arm64 code is larger than 1 MB, the JIT reaches the method's read-only data (RWD constants: Vector128 and double constants, switch jump tables) from points more than 1 MB away with the long forms IF_LARGELDC (adrp + ldr, or adrp + add + ld1 for 16 bytes) and IF_LARGEADR (adrp + add). In emitOutputLJ these are encoded from the addresses of the JIT's temporary code and data buffers:

// src/coreclr/jit/emitarm64.cpp, emitOutputLJ, iiaIsJitDataOffset() path, loadConstant && !idjShort
ssize_t relPageAddr = computeRelPageAddr((size_t)dstAddr, (size_t)srcAddr);
...
ssize_t imm12 = (ssize_t)dstAddr & 0xFFF; // 12 bits

No relocation is recorded for them. The data path for IF_LARGEADR goes through emitOutputLoadLabel, which does record ARM64_PAGEBASE_REL21/ARM64_PAGEOFFSET_12A, but only when id->idIsReloc(), and that is not set for JIT data. adrp depends on the page offset (address & 0xFFF) of the instruction and of its target. The page offset of the buffer the AOT compiler hands the JIT is unrelated to where the method ends up in the image, so the final adrp+offset points at the wrong address. In practice that is usually somewhere else in the code, and the load reads instruction bytes. A switch through a wrong jump table branches to a wrong address and crashes.

With the runtime JIT the buffer is the final code address, so the same encoding is correct. Loads within 1 MB of the data use the short pc-relative ldr/adr, which are position-independent and correct. x64 is unaffected.

Reproduction Steps

A generator writes one method, Huge.Run, with N straight-line calls. It loads a Vector128<ulong> constant, a double constant and (optionally) a switch jump table at its start, and another vector and double at its end.

gen.py:

import sys
n = int(sys.argv[1])
switch = "--no-switch" not in sys.argv[2:]
out = []
w = out.append
w("using System.Runtime.CompilerServices;")
w("using System.Runtime.Intrinsics;")
w("public static class Huge")
w("{")
w("    [MethodImpl(MethodImplOptions.NoInlining)] static int F(int x, int k) => x ^ k;")
w("    [MethodImpl(MethodImplOptions.NoInlining)] static void Show(string what, Vector128<ulong> v) => System.Console.WriteLine($\"{what}: {v.GetElement(0):X16} {v.GetElement(1):X16}\");")
w("    [MethodImpl(MethodImplOptions.NoInlining)] static void Show(string what, double d) => System.Console.WriteLine($\"{what}: {d:R}\");")
w("    public static int Run(int x)")
w("    {")
w("        Show(\"start vector\", Vector128.Create(0x0123456789ABCDEFUL, 0xFEDCBA9876543210UL));")
w("        Show(\"start double\", 3.141592653589793);")
if switch: w("        switch (x) { case 1: Show(\"start switch\", 1.0); break; case 2: Show(\"start switch\", 2.0); break; case 3: Show(\"start switch\", 3.0); break; case 4: Show(\"start switch\", 4.0); break; case 5: Show(\"start switch\", 5.0); break; default: Show(\"start switch\", 0.0); break; }")
for i in range(n):
    w("        x = F(x, %d);" % (i * 7919 % 1000003))
w("        Show(\"end vector  \", Vector128.Create(0x1111111122222222UL, 0x3333333344444444UL));")
w("        Show(\"end double  \", 2.718281828459045);")
w("        return x;")
w("    }")
w("}")
print("\n".join(out))

Program.cs:

System.Console.WriteLine(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription + " " +
                         System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture);
System.Console.WriteLine("result: " + Huge.Run(1));

Repro.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net10.0;net11.0</TargetFrameworks>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
</Project>

Build on linux-x64 and run under arm64 emulation:

python3 gen.py 32000 --no-switch > Huge.cs     # Huge.Run: 416 KB IL, 1.09 MB arm64 code
dotnet publish -c Release -f net10.0 -r linux-arm64 --self-contained -p:PublishReadyToRun=true -o out \
  "-p:PublishReadyToRunCrossgen2ExtraArgs=--map%3B--codegenopt:JitDisasm=Run%3B--codegenopt:JitStdOutFile=$PWD/disasm.txt"
docker run --rm --platform linux/arm64 -v $PWD/out:/app debian:13-slim /app/Repro
docker run --rm --platform linux/arm64 -v $PWD/out:/app -e DOTNET_ReadyToRun=0 debian:13-slim /app/Repro
Expected behavior

The same output with and without the R2R code:

.NET 10.0.15 Arm64
start vector: 0123456789ABCDEF FEDCBA9876543210
start double: 3.141592653589793
end vector  : 1111111122222222 3333333344444444
end double  : 2.718281828459045
result: 591691
Actual behavior

With the R2R code, the two constants loaded at the start of the method (more than 1 MB before the data) are wrong. The ones at the end (short pc-relative ldr) are right:

.NET 10.0.15 Arm64
start vector: 0001355620020010 0200100600000128
start double: 1.60796364073817E-309
end vector  : 1111111122222222 3333333344444444
end double  : 2.718281828459045
result: 591691

.NET 11.0.0 gives the same result (start vector: 0001280001544520 7334200200100600, start double: 1.7713159171880902E-231).

With the switch (python3 gen.py 32000), the jump table is also read from the wrong address. The process dies with SIGSEGV on .NET 10.0.15 and 11.0.0 after printing the two wrong constants. At N=30000 it threw a NullReferenceException in Huge.Run instead.

The JIT's listing (crossgen2 10.0.15, JitDisasm=Run) shows the long forms at the start and the short form at the end:

; Assembly listing for method Huge:Run(int):int (FullOpts)
; ReadyToRun compilation
G_M000_IG02:                ;; offset=0x0048
            adrp    x0, [HIGH RELOC #0x420210]
            add     x0, x0, [LOW RELOC #0x420210]
            ldr     x0, [x0]
            ldr     x0, [x0]
            ldr     q0, (LARGELDC)[@RWD00]
            ...
            ldr     d0, (LARGELDC)[@RWD16]
            ...
            adr     x11, (LARGEADR)[@RWD24]
            ldr     w11, [x11, x0, LSL #2]
            ...
            ldr     q0, [@RWD48]            ; near the end: short pc-relative ldr
            ...
RWD00  	dq	0123456789ABCDEFh, FEDCBA9876543210h
RWD16  	dq	400921FB54442D18h	;   3.14159265
RWD24  	dd	00000120h ; case G_M000_IG08

Decoding the instructions in the published Repro.dll (RVA and size of Huge.Run from crossgen2 --map: 0x00023500 | 0x117AA8 | .text | Repro_Huge__Run (MethodWithGCInfo)) shows where these pairs point:

method RVA 0x23500..0x13AFA8 (1145512 bytes of code + read-only data)
  constant 0123456789ABCDEF is at RVA 0x13AF70 (method+0x117A70)
  constant 400921FB54442D18 is at RVA 0x13AF80 (method+0x117A80)
  method+0x000058: adrp x11, +280 pages; add x11, x11, #0x2D0         -> RVA 0x13B2D0 (.text)
  method+0x000084: adrp x11, +280 pages; ldr x11, [x11, #0x2E0]       -> RVA 0x13B2E0 (.text)

The data is at RVA 0x13AF70, but the pair computes 0x13B2D0. The low 12 bits (0x2D0) are those of the JIT's buffer, not of the image. The decoder is a short Python script that searches for adrp xN followed by an add/ldr on xN, and I can attach it. Every other adrp in the method points at its import cell in .data, through a relocation.

NativeAOT (ILC 10.0.15 and 11.0.0, linux-arm64) has the same bug. ILC's code is smaller, so it needs N=45000 (Repro_Huge__Run, 0x143988 bytes). In Repro.o, every adrp in Huge.Run has an R_AARCH64_ADR_PREL_PG_HI21 relocation except the three for the vector, the double and the jump table:

Run+0x048 adrp x0  reloc: None
Run+0x054 adrp x0  reloc: R_AARCH64_ADR_PREL_PG_HI21 __Str_start_vector_...
Run+0x060 adrp x0  reloc: None
Run+0x06C adrp x0  reloc: R_AARCH64_ADR_PREL_PG_HI21 __Str_start_double_...
Run+0x088 adrp x1  reloc: None

The linked binary prints start vector: 72A0018152920841 B901D7A097FAF23C and start double: 6.97014894305068E+89, then dies with SIGSEGV (.NET 10.0.15; .NET 11.0.0 is the same).

Threshold: the long forms appear as soon as the data is more than 1 MB (the ±1 MB range of ldr-literal/adr) from the load. N=29500 (1,041,592 bytes of code) is correct with no LARGE* instruction. N=30000 (1,062,428 bytes) uses them and fails.

Regression?

Not known. 10.0.15 and 11.0.0 both fail. Older versions were not tested.

Known Workarounds
  • Keep methods under about 1 MB of arm64 code.
  • Leave such methods to the runtime JIT, for example by keeping them out of the R2R image. DOTNET_ReadyToRun=0 makes the repro correct. The runtime JIT (10.0.15) emits the same LARGELDC/LARGEADR forms (Tier0, 1,144,064 bytes; also with DOTNET_TieredCompilation=0) but encodes them from the real code address.
Configuration
  • crossgen2 (microsoft.netcore.app.crossgen2.linux-x64) 10.0.15 and 11.0.0. ILC 10.0.15 and 11.0.0. SDKs 10.0.401 and 11.0.100.
  • Target linux-arm64, self-contained. Run in debian:13-slim (arm64) under qemu-user 10.0.13 on a linux-x64 Debian 13 host.
  • Also seen with osx-arm64 R2R on Apple Silicon, in the project below.
  • x64 is not affected. With .NET 11.0.0 linux-x64 R2R, Huge.Run is precompiled, its data is a separate MethodReadOnlyDataNode reached through relocations, and the output is correct. On .NET 10, x64 crossgen2 does not precompile Huge.Run at all ("Abi unstable type Vector128`1").
Other information

The likely fix is in emitOutputLJ in src/coreclr/jit/emitarm64.cpp (checked at 425a54eb9ab03e2d7c7c3e539aca6800ce0c5d7a). When emitComp->opts.compReloc is set, the IF_LARGELDC path (and IF_LARGEADR on JIT data) should record ARM64_PAGEBASE_REL21 and ARM64_PAGEOFFSET_12A/12L relocations against the data, as emitOutputLoadLabel does for idIsReloc() labels. Alternatively, crossgen2 and ILC could keep the method's data at the page offset the JIT assumed.

Found in IronRuby (https://github.com/Largo/ironruby). Its ahead-of-time startup library compiles the top level of IronRuby's Ruby prelude into one generated method of 484 KB IL (2.1 MB arm64 code), saved with PersistedAssemblyBuilder and then precompiled with crossgen2. In the R2R build, 73 of that method's 103 adrp-based constant loads pointed into code. At run time a symbol name built from one of these constants came out as the bytes of an arm64 epilogue.

Lingua principale
C#
Stelle
18.4k
Fork
5.8k
Merge medio
2g 19h
PR unite (30g)
590

Preparare l'ambiente

Apri in Codespaces

Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di dotnet/runtime

Tutte le issue di dotnet/runtime

Issue simili

Altre issue su C#

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.