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

[Experimental] Add alternative way of resolving UCO function pointers for Marshal Methods #9805

Open
wants to merge 11 commits into
base: main
Choose a base branch
from

Conversation

simonrozsival
Copy link
Member

@simonrozsival simonrozsival commented Feb 18, 2025

This PR aims at creating an alternative to the current implementation of MonodroidRuntime::get_function_pointer which does not use Mono embedding APIs. It is inspired by what xamarin/xamarin-macios does in its "ManagedStaticRegistrar".

The way this alternative function pointer lookup method works is that it generates IL of several lookup tables as part of MarshalMethodsAssemblyRewriter.

Each UCO method is given three indexes: (uint assemblyIndex, uint classIndex, uint methodIndex). These values are later baked into the generated LLVM IR code in MarshalMethodsNativeAssemblyGenerator.

The indexes are used in three different switches generated in different parts of the app:

  1. GetFunctionPointer(int methodIndex) method is added to each class which has marshal methods UCOs
    • The method contains a simple switch over methodIndex and returns the unamanged function pointer to the given UCO.
    • Low risk: Possible collision with existing methods in customer code. Possible resolution would be to make the method name more unique, such as __GetFunctionPointer__.
  2. Java.Interop.__ManagedMarshalMethodsLookupTable__ public class with the GetFunctionPointer(int classIndex, int methodIndex) is added to each assembly which contains classes with marshal methods
    • The method contains a simple switch over cassIndex and calls methods from (1).
    • Special care is given to calling GetFunctionPointer(int methodIndex) on private nested classes (and also protected and private protected). These methods cannot be called directly due to visibility constraint, so additional proxy method(s) are added to their parent class(es) to forward the calls to the nested classes.
    • If there are too many classes form a given assembly with UCOs, this switch might be too long for JIT to compile. To solve this problem, we would need to split the method into several smaller ones, as described in https://github.com/xamarin/xamarin-macios/blob/main/docs/managed-static-registrar.md#method-mapping. I have not implemented this yet, since I am not convinced we will run into the same problem as on iOS, where the UCO methods each have just a single int index and are initially part of one huge switch statement, while we are already splitting the lookup into three levels by the assembly and class.
  3. Java.Interop.ManagedMarshalMethodsLookupTable.GetFunctionPointer(int assemblyIndex, int classIndex, int methodIndex) in Mono.Android is overwritten:
    • The method contains a simple switch over assemblyIndex and calls methods from (2).

The "type mapping" part (parts 2 and 3) should later be replaced by a trimmable solution based on dotnet/runtime#110691

This alternative lookup can be enabled by setting $(_AndroidUseManagedMarshalMethodsLookup)=true alongside with $(AndroidEnableMarshalMethods)=true. It is off by default for Mono and it should be turned on by default with other runtimes when marshal methods are also enabled since they don't have Mono embedding APIs.

When testing with Mono, I saw very similar performance as the existing get_function_pointer method has. The managed implementation was almost as fast (on emulator: median 77μs vs 73μs, average 158μs vs 114μs), but I am not confident I did the meassurements the right way (I just used chrono high_resolution_clock). If performance of this feature is a concern in the comments, I will figure out the proper way to do the meassurements).

I am not set on the naming of the feature. Feedback is very welcome.

/cc @ivanpovazan @vitek-karas

@simonrozsival simonrozsival force-pushed the dev/simonrozsival/managed-get-function-pointer branch from 0be14c1 to 408d8f6 Compare February 18, 2025 09:29
Comment on lines +248 to +257
if (!EnableManagedMarshalMethodsLookup) {
RewriteMarshalMethods (state, brokenExceptionTransitionsEnabled);
state.Classifier.AddSpecialCaseMethods ();
} else {
// We need to run `AddSpecialCaseMethods` before `RewriteMarshalMethods` so that we can see the special case
// methods (such as TypeManager.n_Activate_mm) when generating the managed lookup tables.
state.Classifier.AddSpecialCaseMethods ();
state.ManagedMarshalMethodsLookupInfo = new ManagedMarshalMethodsLookupInfo (Log);
RewriteMarshalMethods (state, brokenExceptionTransitionsEnabled);
}
Copy link
Member Author

Choose a reason for hiding this comment

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

I did not observe any functional changes when swapping the order of AddSpecialCaseMethods and RewriteMarshalMethods. I also don't see a reason that the code in AddSpecialCaseMethods could not run before RewriteMarshalMethods. Nevertheless, I thought it might not be a good idea to change the existing behavior and only change the order when this (experimental) feature is enabled.

@@ -103,6 +105,15 @@ public void Rewrite (bool brokenExceptionTransitions)
}
}

if (managedMarshalMethodsLookupInfo is not null) {
// TODO the code should probably go to different assemblies than Mono.Android (to avoid recursive dependencies)
var rootAssembly = resolver.Resolve ("Mono.Android") ?? throw new InvalidOperationException ($"[{targetArch}] Internal error: unable to load the Mono.Android assembly");
Copy link
Member Author

Choose a reason for hiding this comment

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

Having the root lookup table in Mono.Android might be weird - the assembly will now reference every other assembly in the project and many of those other assemblies will reference Mono.Android back. As @jonathanpeppers suggested, it might be a better idea to move this to a separate standalone assembly which doesn't reference it back. I think that won't be a trivial change, so I'm leaving this change for later and I would like to receive feedback on the rest of the PR first.

Copy link
Member

Choose a reason for hiding this comment

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

It might be weird, I'm inclined to agree with @jonathanpeppers, but this idea requires that ManagedMarshalMethodsLookupTable.GetFunctionPointer be accessible from JNIEnvInit.Initialize(). It would thus need to be in either Mono.Android.dll itself or a dependency we can reasonably update, which would be Mono.Android.Runtime.dll.

Neither are "great".

The other alternative is to follow the pattern of 70bd636 / #9760:

  • Introduce a Microsoft.Android.Runtime.CoreCLR.dll, which itself contains the CoreCLR version of JNIEnvInit.Initialize(). (Probably a some initialization method which chains to JNIEnvInit.InitializeJniRuntime().)
  • Microsoft.Android.Runtime.CoreCLR.dll has all the "glue": it references all the assemblies (post linking), contains theJava.Interop.ManagedMarshalMethodsLookupTable type and ManagedMarshalMethodsLookupTable.GetFunctionPointer() method, etc., etc.

I like this idea, but I'm not sure it's really any different than what's done here: throw everything into Mono.Android.dll.

Copy link
Member Author

Choose a reason for hiding this comment

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

I like the idea of separating the startup logic into an assembly that isn't referenced by anything else in the solution. On the other hand, the code in this PR works as is. I suggest opening an issue to revisit this after #9572 is merged to avoid complex merge conflicts.

@simonrozsival simonrozsival changed the title Add alternative way of resolving UCO function pointers for Marshal Methods [Experimental] Add alternative way of resolving UCO function pointers for Marshal Methods Feb 18, 2025
@grendello
Copy link
Contributor

@simonrozsival for 2. from OP, have you considered using a static sorted array for larger sets and simply binary searching over it? It would avoid JIT problems and binary search performance over relatively small sets we deal with would be very good. You could use a simple switch for a small set (e.g. 25-50 entries) and binary search for anything larger.

@simonrozsival
Copy link
Member Author

@grendello could you please clarify what would be in the array and what would the sorting key be? do you imagine having a static Func<int, IntPtr>[]?

The only thing I'd like to avoid is changing the signature of get_function_pointer that we're calling from the LLVM IR so we don't need to change that part of the generator too much.

@grendello
Copy link
Contributor

@simonrozsival a trick I use in a few places is that I have an array of ints (say, hashes) which I search through using binary search and, if a match is found, I get an index of this match. The index then is used to access another array with a more complex structure, that is the entry which contains the information I need. You could use the same approach, it works surprisingly well.

Copy link
Member

@jonathanpeppers jonathanpeppers left a comment

Choose a reason for hiding this comment

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

There must be some tests that assert against application_config:

CheckMonoComponentsMask(False,False,True,1)

Invalid 'application_config' field count in environment file 'D:\a_work\1\a\TestRelease\02-18_11.13.43\temp\CheckMonoComponentsMaskFalseFalseTrue1\obj\Debug\android\environment.armeabi-v7a.ll'
Expected: 26
But was:  27

They probably just need to be updated.

@vitek-karas
Copy link
Member

The index then is used to access another array with a more complex structure

The benefit of the switch statement is that there's no init cost - with an array, something has to fill the array. With code, there's no such need, the code is just there. With R2R we can AOT this code and thus there's no startup cost.
The other reason for code is that it has a standard representation for "reference to type"/"reference to method" which every tool on IL understands. Storing this information in an array (or any other data structure) has the problem of how to reference metadata in a way which tools which manipulate the metadata would understand.

@vitek-karas
Copy link
Member

Re #1 name collision problem - if we're generating this method in IL (not C#) we can produce so called "unspeakable" name, that is an identifier which is invalid in C#. Roslyn does this for its generated code - the character < is not valid in C# identifier, so Roslyn produces DisplayClass<d> as a name of generated code which can't be seen and/or created in C#. We would just pick a pattern slightly different from Roslyn to not collide with that.

@@ -34,6 +34,7 @@
<type fullname="Android.Runtime.XmlReaderPullParser" />
-->
<type fullname="Java.Interop.TypeManager*JavaTypeManager" />
<type fullname="Java.Interop.ManagedMarshalMethodsLookupTable" preserve="methods" />
Copy link
Member

Choose a reason for hiding this comment

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

Our app size regression tests passed, so this is probably ok. Does the new method remain in Mono now?

Copy link
Member Author

@simonrozsival simonrozsival Feb 19, 2025

Choose a reason for hiding this comment

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

It does. Currently I think this is the only way to implement it, since this class and its UCO methods are only referenced from the C++ code using mono embedding APIs. I don't think it is a big deal because this class is just a tiny stub when we don't generate the additional IL.

I am looking for a better way to implement this at the moment. I was thinking about doing the following:

  • Drop the code I added to src/native/mono/monodroid/xamarin-android-app-context.cc
  • Add a (proper) feature switch that corresponds to the MSBuild property
  • Extend JNIEnvInit.Initialize
    • Return the UCO fnptr for the ManagedMarshalMethodsLookupTable.GetFunctionPointer method if the flag is enabled - likely piggyback on JnienvInitializeArgs* args and add an "out" property
  • If we got the function pointer back from JNIEnvInit.Initialize, pass that down to xamarin_app_init, otherwise use the C++ implementation

This would be trimmable and it would allow us to drop that ugly piece code from xamarin-android-app-context.cc.

The only problem I see is that we're calling xamarin_app_init already in MonodroidRuntime::mono_runtime_init, before we can even call into managed code. I wasn't able to find a case when the special get_function_pointer_at_startup method we use at this stage of startup is used, but I assume this coveres some edge case and it shouldn't be ignored. @grendello do you know in which scenario we need the get_function_pointer_at_startup?

Copy link
Contributor

Choose a reason for hiding this comment

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

get_function_pointer_at_startup is a slight perf enhancement where it doesn't take a lock (actually it's an atomic) when storing function pointer in the cache. We can do that in MonoVM because we know there's just a single thread used while we're initializing the application. Atomic writes can be somewhat costly on some devices, thus the approach.

Copy link
Member

Choose a reason for hiding this comment

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

Extend JNIEnvInit.Initialize
Return the UCO fnptr for the ManagedMarshalMethodsLookupTable.GetFunctionPointer method if the flag is enabled - likely piggyback on JnienvInitializeArgs* args and add an "out" property

+1 on this

Copy link
Member

Choose a reason for hiding this comment

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

@simonrozsival if you manage to go down that road, we should also consider fetching RegisterJniNatives method pointer the same way:

registerType = mono_get_method (mono_android_assembly_image, application_config.jnienv_registerjninatives_method_token, runtime);

Copy link
Member Author

Choose a reason for hiding this comment

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

@grendello I looked at the code and I think I understand what it does. When I added logging to get_function_pointer_at_startup and get_function_pointer_at_runtime, I was only seeing logs from get_function_pointer_at_runtime when running a .NET MAUI app. Is there some special feature I would need to use in my app that would require fetching a fnptr at startup (opening the app using a deep link? or via some shortcut? widgets?).

@@ -1332,5 +1332,24 @@ public void NativeAOTSample ()
throw;
}
}

[Test]
public void AppStartsWithManagedMarshalMethodsLookupEnabled ()
Copy link
Member

Choose a reason for hiding this comment

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

This new test is running successfully:

image

@ivanpovazan
Copy link
Member

Really nice work, well done!

I have only two minor comments:

  1. I think it would useful to add snippets of the generated IL and LLVM IR code to the description of the PR that describe the changes and the new behaviour
  2. @grendello what would be the most precise way of measuring the performance impact of this PR, to put things into perspective?

@grendello
Copy link
Contributor

@ivanpovazan You could use https://github.com/grendello/XAPerfTestRunner to run a sample app with both this PR and main, and compare the results. Marshal methods impact startup performance in a noticeable way, so the tests could be ran in the following configurations:

  1. (main) marshal methods disabled
  2. (main) marshal methods enabled
  3. (PR) managed marshal methods enabled

@@ -132,3 +132,48 @@ MonodroidRuntime::get_function_pointer_at_runtime (uint32_t mono_image_index, ui
{
get_function_pointer<true> (mono_image_index, class_index, method_token, target_ptr);
}

get_function_pointer_fn
Copy link
Member

Choose a reason for hiding this comment

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

Given the intent to have something that works with CoreCLR, which doesn't have an embedding API, the existence of this method at all is slightly concerning.

Sketching a bit, what seems like it should work and would allow removing this method and related MonoVM embedding API calls would be to update src/Mono.Android/Android.Runtime/JNIEnvInit.cs to contain:

partial class JNIEnvInit {
    [DllImport ("xamarin-app")]
    static extern unsafe void xamarin_app_init (IntPtr env, delegate unmanaged <uint, uint, uint, out IntPtr, void> get_function_pointer);

    [UnmanagedCallersOnly]
    internal static unsafe void Initialize (JnienvInitializeArgs* args)
    {
        // …
        delegate unmanaged <uint, uint, uint, out IntPtr, void> get_function_pointer =
            Java.Interop.ManagedMarshalMethodsLookupTable.GetFunctionPointer;
        xamarin_app_init (IntPtr.Zero, get_function_pointer);
        // …
    }
}

This only requires that P/Invokes work, which they should.

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree that this way of resolving the unmanaged delegate isn't great. I discussed one idea I had in an earlier comment but the p/invoke might be cleaner.

Side note: for this particular case, CoreCLR has an "embedding API" aptly named hdt_get_function_pointer

@jonpryor
Copy link
Member

jonpryor commented Feb 21, 2025

Elaborating on this comment, the following patch appears to work when building and running samples/HelloWorld. I've done no further testing, so it's doubtless incomplete, but I like the direction of the following patch more than the current changes to src/native.

diff --git a/external/Java.Interop b/external/Java.Interop
index 2a7183a1c..c1db895c0 160000
--- a/external/Java.Interop
+++ b/external/Java.Interop
@@ -1 +1 @@
-Subproject commit 2a7183a1c0d0f072b90fa836dd39d015d7a8c78d
+Subproject commit c1db895c07c275954ce73f0006a86141af258f60
diff --git a/samples/HelloWorld/HelloWorld/HelloWorld.DotNet.csproj b/samples/HelloWorld/HelloWorld/HelloWorld.DotNet.csproj
index 996e0ddb2..6b14ec133 100644
--- a/samples/HelloWorld/HelloWorld/HelloWorld.DotNet.csproj
+++ b/samples/HelloWorld/HelloWorld/HelloWorld.DotNet.csproj
@@ -3,6 +3,7 @@
     <TargetFramework>$(DotNetAndroidTargetFramework)</TargetFramework>
     <OutputType>Exe</OutputType>
     <RootNamespace>HelloWorld</RootNamespace>
+    <SupportedOSPlatformVersion>24</SupportedOSPlatformVersion>
   </PropertyGroup>
   <ItemGroup>
     <ProjectReference Include="..\HelloLibrary\HelloLibrary.DotNet.csproj" />
diff --git a/samples/HelloWorld/HelloWorld/Properties/AndroidManifest.xml b/samples/HelloWorld/HelloWorld/Properties/AndroidManifest.xml
index 3561f38df..30677a34c 100644
--- a/samples/HelloWorld/HelloWorld/Properties/AndroidManifest.xml
+++ b/samples/HelloWorld/HelloWorld/Properties/AndroidManifest.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="com.xamarin.android.helloworld">
-	<uses-sdk android:minSdkVersion="21" />
+	<uses-sdk android:minSdkVersion="24" />
 	<application android:allowBackup="true" android:icon="@mipmap/icon" android:label="@string/app_name" android:extractNativeLibs="false">
 	</application>
 </manifest>
diff --git a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs
index 7e93806c2..cd4e56b1a 100644
--- a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs
+++ b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs
@@ -86,6 +86,9 @@ namespace Android.Runtime
 			ValueManager = runtime.ValueManager;
 		}
 
+		[DllImport ("xamarin-app")]
+		static extern unsafe void xamarin_app_init (IntPtr env, delegate* unmanaged <int, int, int, IntPtr, void> get_function_pointer);
+
 		[UnmanagedCallersOnly]
 		internal static unsafe void Initialize (JnienvInitializeArgs* args)
 		{
@@ -101,6 +104,9 @@ namespace Android.Runtime
 			ManagedMarshalMethodsLookupEnabled = args->managedMarshalMethodsLookupEnabled;
 			java_class_loader = args->grefLoader;
 
+			delegate* unmanaged <int, int, int, IntPtr, void> get_function_pointer = &ManagedMarshalMethodsLookupTable.GetFunctionPointer;
+			xamarin_app_init (args->env, get_function_pointer);
+
 			BoundExceptionType = (BoundExceptionType)args->ioExceptionType;
 			androidRuntime = new AndroidRuntime (args->env, args->javaVm, args->grefLoader, args->Loader_loadClass, args->jniAddNativeMethodRegistrationAttributePresent != 0);
 			ValueManager = androidRuntime.ValueManager;
diff --git a/src/Mono.Android/Java.Interop/ManagedMarshalMethodsLookupTable.cs b/src/Mono.Android/Java.Interop/ManagedMarshalMethodsLookupTable.cs
index c621a6dad..11b234f76 100644
--- a/src/Mono.Android/Java.Interop/ManagedMarshalMethodsLookupTable.cs
+++ b/src/Mono.Android/Java.Interop/ManagedMarshalMethodsLookupTable.cs
@@ -10,7 +10,7 @@ namespace Java.Interop;
 internal class ManagedMarshalMethodsLookupTable
 {
 	[UnmanagedCallersOnly]
-	static unsafe void GetFunctionPointer (int assemblyIndex, int classIndex, int methodIndex, IntPtr target)
+	internal static unsafe void GetFunctionPointer (int assemblyIndex, int classIndex, int methodIndex, IntPtr target)
 	{
 		try
 		{
@@ -25,6 +25,7 @@ internal class ManagedMarshalMethodsLookupTable
 		catch (Exception ex)
 		{
 			AndroidEnvironment.UnhandledException (ex);
+			AndroidEnvironment.FailFast ("GetFunctionPointer failed: should not be reached");
 		}
 	}
 
diff --git a/src/native/mono/monodroid/monodroid-glue.cc b/src/native/mono/monodroid/monodroid-glue.cc
index a7d20c185..8878fc9b8 100644
--- a/src/native/mono/monodroid/monodroid-glue.cc
+++ b/src/native/mono/monodroid/monodroid-glue.cc
@@ -697,7 +697,7 @@ MonodroidRuntime::mono_runtime_init ([[maybe_unused]] JNIEnv *env, [[maybe_unuse
 		if (!application_config.managed_marshal_methods_lookup_enabled) {
 			xamarin_app_init (env, get_function_pointer_at_startup);
 		} else {
-			xamarin_app_init (env, managed_marshal_method_lookup);
+			// Done in JNIEnvInit.Initialize()
 		}
 	}
 #endif // def RELEASE && def ANDROID && def NET

@simonrozsival
Copy link
Member Author

@jonpryor thanks for the suggestion with the P/Invoke. I will certainly explore it. It's not clear to me why this change requires a bump for the minimum API level from 21 to 24. Could you elaborate on that?

@jonpryor
Copy link
Member

@simonrozsival asked:

It's not clear to me why this change requires a bump for the minimum API level from 21 to 24. Could you elaborate on that?

The bump of $(SupportedOSPlatformVersion) from 21 to 24 was so that I could install the app on my Pixel 8 Pro with Android 14. (I really should update that device…. Also, it still required extra manual steps to install on my device, so that set of changes likely isn't "enough", as dotnet build -t:Install … will fail unless you're at the ready to tap "Allow" or whatever during the install…)

@jonpryor
Copy link
Member

@jonpryor wrote:

The bump of $(SupportedOSPlatformVersion) from 21 to 24 was so that I could install the app on my Pixel 8 Pro with Android 14.

This PR is a better fix for my inability to easily install the HelloWorld sample: #9822

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.

7 participants