Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NativeAOT] Generate optimized type mapping #9856

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from

Conversation

simonrozsival
Copy link
Member

@simonrozsival simonrozsival commented Feb 27, 2025

This PR replaces the current managed type mapping dictionary, which needs to be fully build at startup by individually calling Add methods, with a pre-generated static array of class name hashes.

The array of hashes is sorted so we can use binary search to find the index corresponding to any class name. This idea is not new, it is exacatly how the native type maps work at the moment.

Once we know the index of a class, we can use a generated IL switch to look up the corresponding Type. I chose to generate this jumptable in code because this is what Native AOT understands best. Constructing a static array of metadata tokens is AFAIK not an option for Native AOT.

The generated IL is conceptually equivalent to the following C# code:

static class TypeMapping
{
    public static Type? GetType(string javaClassName)
    {
        ulong hash = Hash(javaClassName);
        int index = BinarySearch(Hashes, hash);
        return GetTypeByIndex(index);
    }

    private static ReadOnlySpan<ulong> Hashes
    {
        get
        {
            var ptr = Unsafe.AsPointer(ref '<PrivateImplementationDetails>'.'<Microsoft.Android.Runtime.TypeMapping>s_hashes');
            return new ReadOnlySpan<ulong>(ptr, 54);
        }
    }

    private static Type? GetTypeByIndex (int index)
        => index switch
        {
            0 => typeof(A),
            1 => typeof(B),
            // ...
            _ => null,
        };
}

The s_hashes field is stored as a byte buffer in the assembly and it is loaded from the static RVA field under <PrivateImplementationDetails>. The relevant generated code looks like this for a simple app (samples/NativeAOT.csproj):

//
// class TypeMapping
//

.field assembly static initonly valuetype Microsoft.Android.Runtime.TypeMapping/HashesArray s_hashes at I_00004A33
.data cil I_00004A33 = bytearray (
	c5 27 01 ad 86 95 90 00 2e b5 ff b6 eb ef 2c 04
	5d 20 71 9d c0 8f 78 09 24 db 2e 9c f9 cc 8d 0a
	bc d1 4e 4c de f1 d9 0b 83 c5 37 e8 d7 c3 b3 0d
	f9 b1 64 fe 72 d0 d1 0f 8e 99 32 40 ab c2 dd 10
	95 3d a9 89 b0 a9 81 12 da a1 1a e9 62 96 ed 1e
	7c 52 0b 4c 25 28 37 26 6c 89 44 40 86 46 7b 2a
	a5 c1 d6 0b 52 8e 84 37 e2 97 e3 1f 8e fa b2 47
	2a 9a c3 8b 8e d0 d8 47 37 b2 3a d6 7d 11 9e 49
	34 29 c1 56 77 21 7b 54 8e 7a 2a 5e 62 1c 2d 5b
	a0 a4 53 91 42 b7 18 60 51 0d 00 bc 15 cc 4e 60
	e7 00 00 75 1c cc 4b 68 fb 35 55 50 bf bc e7 68
	ee 7c 46 a9 1c c5 e8 68 e7 3c 4e 6d 2f b4 b7 6c
	d4 1c 3d 47 2d 2a c0 6c 67 31 a1 c1 cb 5c c2 70
	66 1f cb d6 59 50 76 73 8e 50 c2 47 46 19 8f 76
	0c a3 d9 c9 c1 49 bb 76 47 49 52 5a 0c 86 c7 76
	43 06 de 1a 9c 34 a4 77 07 5a 17 8d 74 58 2c 7c
	d0 05 a6 c5 b5 ff 97 7c f1 36 3a fc 31 bc 8f 86
	35 45 f1 6e 96 88 2a 87 60 b1 04 f9 af c9 ee 89
	12 08 0d 44 6c a0 32 8c 35 3a b0 71 3c 2c 44 91
	c8 25 9d d5 9e 5d 1b 93 09 94 5f 54 87 b1 96 a0
	59 32 69 4d 3f c4 a6 a0 5f 7d 08 b5 e6 2a 9c a8
	ce 20 91 5b 2a c5 8c af 5f 58 9d 1e 18 3f 65 bf
	57 90 af e1 71 13 35 c1 4b b8 2d 6e 9c 25 7a c5
	68 e8 b8 1d 2d 1d 94 d1 5b dc 7a b6 52 f4 cf d4
	56 48 36 d6 2b 3f 18 d7 d0 46 bc 99 55 15 bc da
	c2 5c aa 9f 40 3b 87 ed 68 5c 3c ea 95 3a f2 ef
	b3 c5 64 33 43 f6 0c f1 dc e9 8b df 19 2a 72 f9
)

.method private hidebysig specialname static 
	valuetype [System.Private.CoreLib]System.ReadOnlySpan`1<uint64> get_Hashes () cil managed 
{
	.custom instance void [System.Private.CoreLib]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
		01 00 00 00 00
	)
	// Method begins at RVA 0x42b4
	// Header size: 12
	// Code size: 21 (0x15)
	.maxstack 2
	.locals init (
		[0] valuetype [System.Private.CoreLib]System.ReadOnlySpan`1<uint64>
	)

	IL_0000: ldsflda valuetype Microsoft.Android.Runtime.TypeMapping/HashesArray Microsoft.Android.Runtime.TypeMapping::s_hashes
	IL_0005: call void* [System.Private.CoreLib]System.Runtime.CompilerServices.Unsafe::AsPointer<uint8>(!!0&)
	IL_000a: ldc.i4 54
	IL_000f: newobj instance void valuetype [System.Private.CoreLib]System.ReadOnlySpan`1<uint64>::.ctor(void*, int32)
	IL_0014: ret
} // end of method TypeMapping::get_Hashes

.method private hidebysig static 
	class [System.Private.CoreLib]System.Type GetTypeByIndex (
		int32 index
	) cil managed 
{
	.custom instance void [System.Private.CoreLib]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
		01 00 02 00 00
	)
	// Method begins at RVA 0x4328
	// Header size: 12
	// Code size: 823 (0x337)
	.maxstack 1

	IL_0000: ldarg.0
	IL_0001: switch (IL_00e3, IL_00ee, ..., IL_032a)

	IL_00de: br IL_0335

	IL_00e3: ldtoken [Mono.Android]Java.IO.File
	IL_00e8: call class [System.Private.CoreLib]System.Type [System.Private.CoreLib]System.Type::GetTypeFromHandle(valuetype [System.Private.CoreLib]System.RuntimeTypeHandle)
	IL_00ed: ret

	IL_00ee: ldtoken [Mono.Android]Android.Runtime.InputStreamAdapter
	IL_00f3: call class [System.Private.CoreLib]System.Type [System.Private.CoreLib]System.Type::GetTypeFromHandle(valuetype [System.Private.CoreLib]System.RuntimeTypeHandle)
	IL_00f8: ret

	// ...

	IL_032a: ldtoken [Mono.Android]Java.Lang.StackTraceElement
	IL_032f: call class [System.Private.CoreLib]System.Type [System.Private.CoreLib]System.Type::GetTypeFromHandle(valuetype [System.Private.CoreLib]System.RuntimeTypeHandle)
	IL_0334: ret

	IL_0335: ldnull
	IL_0336: ret
} // end of method TypeMapping::GetTypeByIndex

.class nested private explicit ansi HashesArray
	extends [System.Private.CoreLib]System.ValueType
{
	.pack 1
	.size 432

} // end of class HashesArray

Notes

  • I'm using xxhash64 from the System.IO.Hashing NuGet package. This algorithm is used because it's what we're successfully using in the native type maps. Unfortunately, it presented a challenge when used in a custom linker step. I needed to manually load the assembly into an ALC, because ILLink would not load the package dependency of our custom step assembly automatically.
  • The switch could become too big for the AOT compiler/RyuJIT to compile. This might need to be revisited later and split into several separate methods (see https://github.com/dotnet/macios/blob/main/docs/managed-static-registrar.md#method-mapping for similar approach used in the macios managed static registrar)
  • I'm not very happy with the inverse lookups (Type -> java class name). The TypeManager.GetSimpleReferences method CAN return multiple values. It's not clear to me when this can happen (invokers?) and if the inverse type mapping can also be just 1:1. If that were the case, the related code could be revisited and optimized. I at least added caching for the time being because this method is repeatedly called with the same inputs at the startup of MAUI apps.

/cc @ivanpovazan @vitek-karas @AaronRobinsonMSFT

Comment on lines 39 to 40
_hashingAssemblyLoadContext = new AssemblyLoadContext (name: null, isCollectible: true);
_hashMethod = GetHashMethod (_hashingAssemblyLoadContext, assemblyPath);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, this is because we had trouble getting illink to load an assembly reference of a trimmer step?

Was the problem, just getting it to load? Or is a different System.IO.Hashing.dll already loaded in the process and being used during illink?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was the problem, just getting it to load?

Yes, the problem was that it wouldn't load it at all. I tried copying the assembly into several locations, where I expected the assembly loader to look for the dependencies, but I couldn't get it to load.

ILLink uses AssemblyLoadContext.LoadFromAssemblyPath internally and it appears that it won't load other referenced assemblies if they aren't included in the trimmer's deps.json file?

@sbomer do you have any suggestions how to make sure the System.IO.Hashing.dll dependency of our custom liker step is loaded without doing it automatically?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wondered if it would load if you add the assembly to @(_TrimmerCustomSteps) with no metadata:

<_TrimmerCustomSteps Include="$(_AndroidLinkerCustomStepAssembly)" Type="Microsoft.Android.Sdk.ILLink.PreserveSubStepDispatcher" />

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I know LoadFromAssemblyPath doesn't resolve dependencies, and we don't want to be in the business of handling potential conflicts between custom step dependencies, so a custom ALC is probably the best supported way to do this. Adding a custom step with the dependency might get it to load, but I think will cause trimming to fail when the custom step isn't found.

@simonrozsival simonrozsival force-pushed the dev/simonrozsival/managed-jni-to-managed-type-map branch from 410116e to 99a21e7 Compare February 27, 2025 17:17

// Create static field to store the raw bytes
var bytesField = new FieldDefinition ("s_hashes", FieldAttributes.Private | FieldAttributes.Static | FieldAttributes.InitOnly, arrayType);
bytesField.InitialValue = hashes.Select(h => BitConverter.GetBytes (h)).SelectMany (x => x).ToArray ();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there endianness issues here? For example, will the host (IL generator) and target always have the same endianness?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I need to revisit this.

throw new InvalidOperationException ($"The '{SystemIOHashingAssemblyPathCustomData}' custom data must point to a valid assembly path ('{assemblyPath}' does not exist)");
}

_hashingAssemblyLoadContext = new AssemblyLoadContext (name: null, isCollectible: true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do not come up with something better, you can just use Assembly.LoadFile here. Assembly.LoadFile does exactly what you are doing here: load an assembly into its own isolated AssemblyLoadContext.

Co-authored-by: Aaron Robinson <arobins@microsoft.com>
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
@jonpryor
Copy link
Member

@jonathanpeppers, @pjcollins: any idea why make create-installers is failing:

         /Users/builder/azdo/_work/4/s/xamarin-android/bin/Release/dotnet/sdk/10.0.100-preview.3.25122.1/Sdks/NuGet.Build.Tasks.Pack/build/NuGet.Build.Tasks.Pack.targets(221,5): error NU5019: File not found: '/Users/builder/azdo/_work/4/s/xamarin-android/bin/Release/lib/xamarin.android/xbuild-frameworks/Microsoft.Android/35/System.IO.Hashing.pdb'. [/Users/builder/azdo/_work/4/s/xamarin-android/build-tools/create-packs/Microsoft.Android.Runtime.proj] [/Users/builder/azdo/_work/4/s/xamarin-android/build-tools/create-packs/Microsoft.Android.Sdk.proj]
         /Users/builder/azdo/_work/4/s/xamarin-android/build-tools/create-packs/Directory.Build.targets(72,5): error MSB3073: The command ""/Users/builder/azdo/_work/4/s/xamarin-android/bin/Release/dotnet/dotnet" pack -p:Configuration=Release -p:IncludeSymbols=False -p:AndroidApiLevel=36 -p:AndroidRID=android-arm -p:AndroidRuntime=NativeAOT "/Users/builder/azdo/_work/4/s/xamarin-android/build-tools/create-packs/Microsoft.Android.Runtime.proj"" exited with code 1. [/Users/builder/azdo/_work/4/s/xamarin-android/build-tools/create-packs/Microsoft.Android.Sdk.proj]

@pjcollins
Copy link
Member

@jonathanpeppers, @pjcollins: any idea why make create-installers is failing:

         /Users/builder/azdo/_work/4/s/xamarin-android/bin/Release/dotnet/sdk/10.0.100-preview.3.25122.1/Sdks/NuGet.Build.Tasks.Pack/build/NuGet.Build.Tasks.Pack.targets(221,5): error NU5019: File not found: '/Users/builder/azdo/_work/4/s/xamarin-android/bin/Release/lib/xamarin.android/xbuild-frameworks/Microsoft.Android/35/System.IO.Hashing.pdb'. [/Users/builder/azdo/_work/4/s/xamarin-android/build-tools/create-packs/Microsoft.Android.Runtime.proj] [/Users/builder/azdo/_work/4/s/xamarin-android/build-tools/create-packs/Microsoft.Android.Sdk.proj]
         /Users/builder/azdo/_work/4/s/xamarin-android/build-tools/create-packs/Directory.Build.targets(72,5): error MSB3073: The command ""/Users/builder/azdo/_work/4/s/xamarin-android/bin/Release/dotnet/dotnet" pack -p:Configuration=Release -p:IncludeSymbols=False -p:AndroidApiLevel=36 -p:AndroidRID=android-arm -p:AndroidRuntime=NativeAOT "/Users/builder/azdo/_work/4/s/xamarin-android/build-tools/create-packs/Microsoft.Android.Runtime.proj"" exited with code 1. [/Users/builder/azdo/_work/4/s/xamarin-android/build-tools/create-packs/Microsoft.Android.Sdk.proj]

We automatically try to pull in .pdb files for all @(_AndroidRuntimePackAssemblies) here -

<_PackageFiles Include="@(_AndroidRuntimePackAssemblies->'%(RelativeDir)%(Filename).pdb')" PackagePath="$(_AndroidRuntimePackAssemblyPath)" />
. I think System.IO.Hashing.dll is coming from a package reference and doesn't have an easily accessible .pdb file. We'll need to change the way we're including System.IO.Hashing.dll in the runtime packs, assuming we actually need to redistribute it there?

{
ulong hash = Hash (javaClassName);

// the hashes array is sorted and all the hashes are unique
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you going to check the uniqueness of the hashcodes at build time to guarantee this?

An alternative way to deal with this would be to allow non-unique hashcodes and check all candidates in the map. If you do that, you can change the hashcodes to 32-bit that makes the map 2x smaller and dealing with hashcodes faster, at the cost of a rare hash collision.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you going to check the uniqueness of the hashcodes at build time to guarantee this?

Yes, the check is here: https://github.com/dotnet/android/pull/9856/files#diff-8e22a99ebc58c9417a48013888fc90d72fa6b638aff5d2d449cbf2c78bd0a8fbR149-R161

If you do that, you can change the hashcodes to 32-bit that makes the map 2x smaller and dealing with hashcodes faster, at the cost of a rare hash collision.

That's definitely worth trying. Assuming even the XxHash32 collisions are very rare, in the typical case there should be just 1 string comparison to verify that the hash belongs to the expected result.

simonrozsival and others added 2 commits February 28, 2025 21:56
…AOT/TypeMapping.cs

Co-authored-by: Jan Kotas <jkotas@microsoft.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants