This commit is contained in:
Simon Rozsival 2023-10-25 14:41:31 +02:00
Родитель 42ea483e70
Коммит dc354cf59f
1 изменённых файлов: 483 добавлений и 0 удалений

Просмотреть файл

@ -0,0 +1,483 @@
# Trimming-friendly Registrar
Author: Simon Rozsival
Last update: Oct 23, 2023
## Introduction
When I first started looking into how to remove the dependency on Custom Linker Steps in the build process of
iOS apps using NativeAOT, I tried coming up with ideas how to migrate the code that generates the Managed Static
Registrar (MSR). This effort failed because the MSR needs to generate code _between_ the
marking/dependency analysis phase of a linker and the native codegen phase and implementing this logic directly into
ILCompiler wasn't an option.
After becoming more familiar with the Xamarin codebase and learning how it works on low levels, I think that in order
to remove custom linker steps, we cannot just migrate the code that generates any of the existing registrars out of
the custom linker steps. We need a _new registrar_ that can be generated independently of any linker.
In this document, I describe my idea of transforming the existing MSR to make it fully trimmable.
---
## Why is the current registrar not trimmable?
### TL;DR
The current registrar requires several lookup tables (both in .NET and in Objective-C) which we can generate only once we
know which types survived trimming. We use these lookup tables to create managed object instances for existing native
objects and to create instances of native objects for existing managed objects.
These methods can't be generated _before_ trimming as they would root all NSObject subclasses.
I propose removing the lookup tables and instead use Objective-C virtual method dispatch and Objective-C category methods
with specific naming conventions.
### The long version
The current managed static registrar has quite complicated logic to create an instance of a .NET object for an existing
native object. It is built as an extension of the existing _static_ registrar and it still uses the same logic to
find which .NET type maps to which Objective-C type.
Consider the following example:
```c#
class NSDictionary : NSObject { /* ... */ }
class NSMutableDictionary : NSDictionary { /* ... */ }
// app code:
[Export("getDictionaryItem:atIndex:")]
public void GetDictionaryItem(NSDictionary dictionary, int i) { /* ... */ }
// generated code:
[UnmanagedCallersOnly(EntryPoint = "_registrar__...")]
public static void GetDictionaryItem(IntPtr pobj, IntPtr pDictionary, int i)
{
// ...
NSDictionary dictionary = Runtime.GetNSObject<NSDictionary>(pDictionary);
// ...
}
```
__Note:__ All code examples in this document are simplified and aren't production-ready. For example, I omitted
exception handling in UCO methods and I removed the `__Registrar_Callbacks__` class to reduce nesting.
In the generated method, we need to call the instance method on a managed object corresponding to the native object
based on the handle of its peer native object. The `Runtime.GetNSObject<T>(IntPtr handle)` method resolves the managed
instance using the following logic:
1. If there's an existing .NET object instance for the native object and it's of type `T`, _return it_.
2. Otherwise, find the .NET type that corresponds to the native object (note that we cannot simply use the type from the
managed method signature).
3. Create an instance of the .NET type and pass the native handle to the constructor.
If the native object corresponding to `pDictionary` is an instance of Objective-C `NSMutableDictionary` class and there
isn't an existing managed object mapped to the handle, the `GetNSObject` method should return a new instance of the .NET
`Foundation.NSMutableDictionary` class.
The internal implementation of the `GetNSObject` method currently works as follows:
1. It determines the Objective-C class of the native object (using `objc_getClass`)
2. It finds the "type ID" of the .NET type corresponding to the native class using the generated `MTRegistrationMap`
native lookup table
- _Note_: We use generated "type IDs" to emulate type metadata tokens which aren't available in NativeAOT.
3. It finds the .NET `RuntimeTypeHandle` of the type that corresponds to the "type ID" using a generated lookup
function `RuntimeTypeHandle LookupType(uint typeId)`
4. It creates an instance of the .NET object using the generated lookup function
`NSObject ConstructNSObject(RuntimeTypeHandle typeHandle, IntPtr nativeInstanceHandle)`
There is also an inverse mechanism which is simpler. We have one more lookup function `uint LookupTypeId(RuntimeTypeHandle runtimeHandle)`
that's used in combination with the already mentioned `MTRegistrationMap` to create instances of native objects for a given
.NET object instance.
## Proposed Solution
### TL;DR
Instead of asking a native object what its native class is and looking up the corresponding .NET type and instantiating it,
we can ask the native object to create an instance of its managed counterpart directly.
We are in control of the generated Objective-C code for the classes we fully generate and we can attach instance methods
to existing classes using Objective-C categories.
### The long version
We can generate an Objective-C instance method `createManagedInstance` when we generate Objective-C classes for NSObject
subclasses defined in the .NET code. We can generate the same method as a category method (Objective-C "extension"
method) for existing Objective-C classes that just have .NET bindings.
For existing types which we can't extend with category methods (C-style structs) and for protocols, we'll need to generate
"fallback" factory methods on a custom `__dotnet` Objective-C class with a specifically named selector.
These Objective-C methods will call to an `[UnmanagedCallersOnly]` method that will create the managed object instance
and return a GCHandle back to Objective-C. This "P/Invoke + reverse P/Invoke" combo will resolve the correct type using
Objective-C virtual dispatch mechanism. None of the three lookup tables mentioned before will be necessary anymore.
The generated code for our example could look something like this (all error handling is omitted):
```c#
partial class NSDictionary {
[UnmanagedCallersOnly(EntryPoint = "_registrar__Foundation_NSDictionary__createManagedInstance")]
public static IntPtr CreateManagedInstance(IntPtr handle)
=> GCHandle.Alloc(new NSDictionary(handle)).ToIntPtr();
}
partial class NSMutableDictionary {
[UnmanagedCallersOnly(EntryPoint = "_registrar__Foundation_NSMutableDictionary__createManagedInstance")]
public static IntPtr CreateManagedInstance(IntPtr handle)
=> GCHandle.Alloc(new NSMutableDictionary(handle)).ToIntPtr();
}
```
```obj-c
@class __dotnet;
@implementation NSDictionary (__dotnet)
id _registrar__Foundation_NSDictionary__createManagedInstance(id handle);
-(id) createManagedInstance {
return _registrar__Foundation_NSDictionary__createManagedInstance(self);
}
@end
@implementation NSMutableDictionary (__dotnet)
id _registrar__Foundation_NSMutableDictionary__createManagedInstance(id handle);
-(id) createManagedInstance {
return _registrar__Foundation_NSMutableDictionary__createManagedInstance(self);
}
@end
```
For the C-style structs and protocols with protocol wrappers we could generate this code:
```c#
partial class CGRect {
[UnmanagedCallersOnly(EntryPoint = "_registrar__CoreGraphics_CGRect__CreateManagedInstance")]
public static IntPtr CreateManagedInstance(IntPtr handle)
=> GCHandle.Alloc (new CGRect (handle, owns: false)).ToIntPtr ();
}
[Protocol(WrapperType = typeof(MyProtocolWrapper))]
interface MyProtocol { /* ... */ }
partial class MyProtocolWrapper {
[UnmanagedCallersOnly(EntryPoint = "_registrar__MyProtocol__CreateManagedInstance")]
public static IntPtr CreateManagedInstance(IntPtr handle)
=> GCHandle.Alloc (new MyProtocolWrapper (handle)).ToIntPtr ();
}
```
```obj-c
@implementation __dotnet (CGRect)
id _registrar__CoreGraphics_CGRect__CreateManagedInstance (id handle);
+(id) createManagedInstance_CoreGraphics_CGRect: (id) handle {
return _registrar__CoreGraphics_CGRect__CreateManagedInstance (handle);
}
@end
@implementation __dotnet (MyProtocol)
id _registrar__MyProtocol__CreateManagedInstance (id handle);
+(id) createManagedInstance_MyProtocol: (id) handle {
return _registrar__MyProtocol__CreateManagedInstance (handle);
}
@end
```
The `GetNSObject` method can then take advantage of the generated methods like this:
```c#
partial static class Runtime {
private static Selector respondsToSelector = Selector.GetHandle("respondsToSelector:");
private static Selector createManagedInstance = Selector.GetHandle("createManagedInstance");
private static Class dotnetClass = Class.GetHandle("__dotnet");
public static T? GetNSObject<T> (NativeHandle handle)
{
if (handle == NativeHandle.Zero) {
return default;
}
var instance = TryGetManagedInstance<T> (handle) ?? TryCreateManagedInstance<T> (handle);
if (instance is null) {
// ... in some cases we want to throw an exception ...
}
return instance;
}
public static T? TryGetManagedInstance<T>(NativeHandle handle)
where T : INativeObject
{
if (handle == IntPtr.Zero) {
return null;
}
if (object_map.TryGetValue(handle, out INativeObject someNativeObject) && someNativeObject is T nativeObject) {
// ... handle the case when an nsobject is in finalizer queue ...
return nativeObject;
}
return null;
}
public static T? TryCreateManagedInstance<T>(NativeHandle handle)
where T : INativeObject
{
if (handle == IntPtr.Zero) {
return null;
}
var canCreateManagedInstance = Messaging.bool_objc_msgSend(handle, respondsToSelector, createManagedInstance);
if (canCreateManagedInstance) {
var managedHandle = Messaging.IntPtr_objc_msgSend(handle, createManagedInstance);
return GCHandle.FromIntPtr(managedHandle).Target as T;
}
// fallback for C-style structs and protocols
var fallbackSelector = Selector.GetHandle ($"createManagedInstance_{MangleName (typeof (T))}:");
var canCreateManagedInstanceUsingFallback = class_respondsToSelector (dotnetClass.Handle, fallbackSelector) == 1;
if (canCreateManagedInstanceUsingFallback) {
var managedHandle = Messaging.IntPtr_objc_msgSend(dotnetClass.Handle, fallbackSelector, handle);
return GCHandle.FromIntPtr(managedHandle).Target as T;
}
return null;
}
}
```
There is a similar solution for the inverse direction in which we want to create a new instance of a native object
for an existing .NET object (which boils down to the implementation of `Class.FindClass(Type type, out bool isCustomType)`):
```c#
[Register("MyNativeClass")]
public class MyClass : NSObject {}
```
```obj-c
@implementation __dotnet (MyNativeClass)
+(id) getNativeClass_MyNamespace_MyClass: (BOOL*) isCustomType {
*isCustomType = YES;
return [MyNativeClass class];
}
@end
```
```c#
partial class Class {
private static IntPtr s_dotnetTypesClass = Class.GetHandle("__dotnet");
public unsafe static IntPtr FindClass(Type type, out bool isCustomType) {
byte _isCustomType = 0;
var selector = Selector.GetHandle($"getNativeClass_{MangleName(type)}:");
var classHandle = Messaging.IntPtr_objc_msgSend_ref_byte(s_dotnetTypesClass, selector, &_isCustomType);
isCustomType = _isCustomType == 1;
return classHandle;
}
}
```
## Dependencies between native and managed code - considerations for guiding IL Compiler
The registrar allows native Objective-C code to create instances of managed classes and call methods on them. In fact,
some managed objects might only be instantiated from Objective-C and some their methods might only ever be called from
Objective-C. In these cases, we need to guide the trimmer to correctly preserve the code we need. Some of the scenarios
we need to express are these:
- "Keep this class and all its members. It is only ever created from Objective-C and only called from Objective-C."
- "Keep all the members of this class if this class survives trimming. Some of the members will only be called through UCOs."
In Xamarin, it was a common practice to use `[Preserve(AllMembers = true)]` and `[Preserve(Conditional = true)]`
attributes to express these scenarios. In NativeAOT, we can "strategically" place `[DynamicDependency]` attributes
on module initializers or class and instance constructors to emulate similar behaviour today.
Unfortunately, `DynamicDependency` implies use of reflection. Therefore, the IL Compiler emits unnecessary metadata.
Ideally, we would have some additional attribute to express "static" or "implicit" dependency. It would also help if we
could express "inverse" dependency as discussed in https://github.com/dotnet/runtime/issues/50333.
The current .NET 8 release of NativeAOT on iOS doesn't need the inverse dependency because it uses Mono.Cecil to place
the attributes wherever needed and generate any missing constructors.
### Trimmable `[UnmanagedCallersOnly]` methods with explicit entry point names?
Unlike ILLink, the ILCompiler currently doesn't support trimming UCOs with an EntryPoint. The UCO entry point is considered
public API surface of the binary and so it's also a root of dependency-analysis the same way the `Main` method is.
If we wanted to pre-generate all the UCO entry points for all NSObject subclasses in .NET MAUI or some
other UI control library, we couldn't trim any of those controls even though they aren't used anywhere in the app.
In order to implement the fully trimmable registrar, we need to be able to tell ILCompiler to behave the same way as ILLink
and allow trimming UCOs with explicit entry point names and also a way to reliably express the "conditionally preserve
all members" semantic.
```c#
public partial class MyClass : NSObject {
[Export("myMethod")]
public void MyMethod() {}
}
// generated code
partial class MyClass {
[DynamicDependency(DynamicallyAccessedMembers.PublicMethods, typeof(MyClass.__Registrar_Callbacks__))] // MyClass is preserved => preserve all methods of __Registrar_Callbacks__
static MyClass() {} // this would be a problem for roslyn source generators -> if the class already has a static constructor, this can't be generated 🤔
// [Preserve(Conditional = true, AllMembers = true)] // -- we need to express the semantic of this Xamarin concept
private static class __Registrar_Callbacks__ {
[UnmanagedCallersOnly(EntryPoint = "_registrar_MyNamespace_MyClass_myMethod")]
// if we had "inverse dependency" attribute what would be its target in this case?
// - it couldn't be MyMethod because that method might only be called from the UCO and we can't guarantee that it
// won't be trimmed
// - the #cctor of MyClass? (we could source-generate a static cctor if there isn't one)
// - this approach works in .NET 8, but is it a reliable method going forward?
// - DynamicallyAccessedMemberTypes.All?
// - would that generate way too much metadata?
public void MyMethod(IntPtr pobj, IntPtr sel) {
MyClass obj = Runtime.TryGetManagedInstance(pobj) ?? Runtime.TryCreateManagedInstance(pobj) ?? throw new Exception("...");
obj.MyMethod(); // this method is preserved => preserve the exported method
}
}
}
```
---
## Cherry-picking the native side of the registrar
To make .NET classes available to the Objective-C world, we must generate Objective-C proxy classes or categories which
forward all calls to the exported selectors to the original .NET class via reverse P/Invokes.
After trimming, we need to detect which .NET classes survived trimming and which Objective-C classes and categories
we need to include in the app (and avoid using the rest of the pre-generated Objective-C code).
I propose the following process:
- Generate all the Objective-C classes while we generate the C#/IL code before trimming
- Put each generated Objective-C class or category into its separate `<objc-name>.h` + `<objc-name>.mm` files
- Store additional information about the native dependencies of the class based on the parent class, implemented protocols,
and method parameters and return values
- Frameworks
- Third-party static libraries
- Other generated Objective-C classes
- After ILC produces the object file, collect the information about the Objective-C classes we need to include
- Scan the binary for undefined symbols which follow a specific naming convention which would include the Objective-C class name:
```bash
$ nm -u aoted-app.o | grep '^_registrar__'
```
- Collect the Objective-C source files from the required Objective-C classes and their dependencies
- Build the Objective-C source files into a second object file
- Link the AOTed object file together with the second object file + all the frameworks and third-party libraries
### Registrar endpoint naming convention
The names of the registrar callbacks should contain the Objective-C class or category name we should include and it should
be easy to reliably parse the endpoint name: `_registrar__<L>_<objc-name>_<N>_<sanitized-selector>`.
- The `L` would be the length of the `objc-name` string (could be encoded as dec, hex, base64)
- The `sanitized-selector` would be the selector where `:` symbols are replaced with `_`.
- The `N` is the index of the selector in an array of sorted selectors of the class/category
- this is a form of de-duplication to avoid name clashes caused by the sanitization (for example `some:_selector` vs
`some_:selector`)
- the `sanitized-selector` is there mostly for debugging purposes to appear in stack traces, the selector would be
unique even without it
---
## How to generate the registrar?
I see three ways of generating the registrar:
- Keep using custom linker steps.
- Use a pre-trimming MSBuild task that's based on Mono.Cecil (independent of ILLink).
- Use roslyn source generators.
- the generated and pre-built code will have to be distributed with the libraries (the same way the bgen output is
pre-built and distributed with libraries)
### Option A: Custom linker steps
We can modify the existing code that generates the managed static registrar to generate the trimmable variant. This could
be useful to validate the idea and iron out the design before we put any effort into a proper implementation.
PoC: https://github.com/xamarin/xamarin-macios/compare/main...simonrozsival:xamarin-macios:net8.0-poc-trimmable-msr
### Option B: Pre-trimming + post-trimming MSBuild tasks
We would modify the DLLs directly before the code is AOTed. Instead of ILLink and custom linker steps, we would create
an independent MSBuild task to perform the weaving. This would include generating the `__Registrar_Callback__` nested
classes with UCO endpoints, proxy classes for generic types, and generating any `[DynamicDependency]` or other attributes
to express all inverse dependencies. At the same time, we would generate the Objective-C code and any information about
native dependencies (frameworks, static libraries, other generated Objective-C source files).
It should only be necessary to pre-process any third-party libraries and the runtimepack DLLs once after restore and not
with every publish.
The post-trimming task would analyze the object file produced by ILC and collect all the Objective-C source files
based on the registrar callbacks that survived trimming and their dependencies. These source files will be compiled
and linked with the AOTed binary.
- **Pros**:
- generated code isn't distributed with runtimepack/nupkg
- it would be an invisible change for libraries authors - they won't need to rebuild their apps for .NET 9
- when we generate IL directly, we have control over visibility of certain members and nested types if needed
- we can reuse some code from the current codebase if we implement the pre-trimming task using Mono.Cecil
- **Cons**:
- slower than source generators - all code is generated when publishing
### Option C: Source generator + post-trimming MSBuild task
The code for the UCO callbacks should be expressible in C#. The only problem that we might have to work around is the
absence of the "inverse dependency" attributes. It might be a good use case to drive the implementation of this feature.
The post-trimming task would be exactly the same as in Option B.
- **Pros**:
- likely faster than an IL-weaving MSBuild task
- code is generated while editing the source files
- the code for the xamarin runtime and for all libraries (e.g, MAUI) would already be pre-built in managed DLLs
- code generator can produce errors and warnings while editing the source files for better DX
- source generators are more future-proof than Mono.Cecil
- **Cons**:
- the generated code has to be distributed with runtimepack/nupkg
- all libraries authors will have to publish their code specifically for .NET 9 and any future releases
which change the registrar
- generating Objective-C code in roslyn source generators is a _hack_ that isn't officially supported
- source generators are _synchronous_ and doing many IO operations will slow them down, causing lagging in the editor
- its's not yet clear how to correctly generate the `[DynamicDependency]` attributes and if the inverse dependency
attributes could be added to .NET
### Notes
- I suggest implementing the change with the Mono.Cecil-based approach since it should be much faster to implement and
overall less risky.
- The C# source generator is a more tempting option and probably more future-proof.
- I'm worried it could take a lot longer to implement and there might have problems with third party libraries.
- This registrar relies on static linking and doesn't support the non-AOT scenario (using `xamarin_registrar_dlsym` to
load function pointers to the registrar callbacks).
- This is not a problem in the context of NativeAOT but it might mean that the registrar could not completely replace
the existing MSR and static registrar for Mono-based (CoreCLR-based?) scenarios and there will still be need
to keep the custom linker steps around to build those registrars.
- There are certainly edge-cases that aren't covered in this document but they should all be possible to implement
using the same ideas that are outlined in this document.
- This claim needs validation though. Are there some obvious cases which would be hard or obviously inefficient
to implement this way?
- It is unclear what would be the file size and startup or runtime performance implications of these changes.
- We will need to generate less managed code but on the other hand we'll generate more Objective-C code.
- There will be more transitions between managed and native code. Does it matter too much in the context of NativeAOT
though? Will it be worse than performing linear search in the lookup tables that we do today?
---
## Risks and Benefits
### Benefits
- It should be straightforward to modify the existing runtime code and custom linker steps to validate the proposed approach.
- We would be able to completely drop IL Linker from the build process when publishing with NativeAOT.
- The new design will be trimmable using any IL linking tool.
- There is potential for file size and performance improvements, although this needs validating.
- It should be possible to generate the code of the registrar using roslyn source generator instead of IL weaving.
### Risks
- We still need the current setup for all the cases which are unsupported by this scenario (inner dev loop, support
for existing codebases that rely on the static registrar). The maintenance cost might not decrease, but instead
increase because now there are two very different scenarios we need to support. We would probably need to deprecate
the static registrar completely for this change to make sense from maintenance perspective.
- On the other hand, we won't be able to deprecate the current setup unless we introduce an alternative and support
both of these scenarios until all customers are able to migrate to the new setup.
- There is some missing functionality in the IL Compiler that would be required to implement the fully-trimmable registrar.
- If we go the source-generator route:
- it will have implications for library authors who will have to rebuild and re-release
their codebases for the new version of .NET,
- This is similar to how the code generated by bgen needs to be re-released. Is this really a problem?
- implementation won't be trivial and we won't be able to reuse much of the code from the existing codebase.