[generator] Prototype typed UCO binding callbacks - #12670
Draft
simonrozsival wants to merge 3 commits into
Draft
Conversation
…vestigation)
Companion to the RAW-ABI variant (af1e96374). Where the raw variant makes
each `n_*` binding callback `[UnmanagedCallersOnly]` but keeps the existing
`JniMarshal.SafeInvokeAction/Func` body, this variant additionally hoists the
*typed* marshaling work into a shared generic helper so that the emitted
per-method code shrinks to a `delegate* managed<...>` load plus one call.
Generated shape (non-generic types, supported signatures):
[UnmanagedCallersOnly]
static int n_GetFlags (IntPtr jnienv, IntPtr native__this, IntPtr native_o)
=> JniMarshalTyped.SafeInvokeMarshaled_O_S<MyType, Other, int> (
jnienv, native__this, native_o, &__n_GetFlags);
static int __n_GetFlags (MyType __this, Other? o) => __this.GetFlags (o);
`JniMarshalTyped` (60 helpers, generated by JniMarshalTyped.tt) owns the whole
transition: WaitForBridgeProcessing, BeginMarshalMethod, peer lookup for
`self`, per-argument `GetObject<T>` conversion, the managed invocation, JNI
local-ref conversion of an object return, `OnUserUnhandledException` and
EndMarshalMethod. Helpers are name-encoded by shape (`O` = object/peer,
`S` = scalar, return `X`/`S`/`O`) rather than by marker types, so no extra
TypeSpec/interface metadata is introduced. Object returns are deliberately
non-generic (target returns IJavaObject?) because bound methods frequently
have covariant returns.
`cb_*` delegate fields and `Get*Handler` connector methods are no longer
emitted for UCO callbacks. `[Register]` connector *strings* are unchanged:
the trimmable typemap already derives `n_{Name}{IDSignature}` itself, so
direct binding is driven by a new versioned assembly marker
(`[assembly: JavaPeerCallbackFormat(2)]`) plus the actual
`[UnmanagedCallersOnly]` attribute on the resolved callback. Legacy connector
assemblies and mixed apps therefore keep working unchanged.
Trimmable typemap RegisterNatives now `ldftn`s external UCO callbacks
directly instead of routing them through a generated forwarding wrapper, and
extends IgnoresAccessChecksTo to cover those assemblies. UCO methods are
never called from managed code (verified by IL scan: 0 managed calls across
192,798 methods).
Fallbacks, in order of preference:
* shapes the shared helper cannot own safely (strings, arrays/copy-back,
collection/stream projections, ICharSequence, sender parameters, arity
beyond the helper set) keep `[UnmanagedCallersOnly]` but use the raw
`JniMarshal.SafeInvoke*` body;
* generic declaring types and generic methods/parameters cannot host
`[UnmanagedCallersOnly]` at all and keep the full legacy shape.
The `_JniMarshal_*` delegate type declarations are intentionally retained:
the hand-written JNINativeWrapper.g.cs still references seven of them.
Removing those 641 TypeDefs is a further, separate saving.
Selection is internal and opt-in; no defaults change. Enable with
`-p:_MonoAndroidEnableUnmanagedCallersOnlyCallbacks=true` (Mono.Android) or
the `EnableUnmanagedCallersOnlyCallbacks` Generator task property.
Mono.Android (Debug, API 37.0) versus main: -4,403,200 bytes (-9.7%),
MethodDef -12.0%, Field -31.1%, #Strings -46.2%, IL -12.7%, StandAloneSig
-35.5%; MethodSpec +8,638 from the generic helper instantiations.
Callback coverage: 19,623 typed (65.5%), 8,064 raw UCO (26.9%),
2,280 legacy (7.6%).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
`CanUseUnmanagedCallersOnly` rejected any method where `Method.IsGeneric` or `ParameterList.HasGeneric` was true. Both properties report *Java* generics, which are erased to plain object references at the JNI boundary, so the emitted `n_*` entry point is non-generic regardless and is perfectly legal to mark `[UnmanagedCallersOnly]`. Only a generic *declaring type* actually prevents it, and that check is unchanged. These methods are still not typed-shape eligible -- the shared helper cannot know the erased type -- so they take the raw `JniMarshal.SafeInvoke*` fallback. This brings the typed variant to exact parity with the RAW-ABI variant (af1e96374) at 28,156 `[UnmanagedCallersOnly]` methods, which makes the two directly comparable, and moves 462 callbacks out of the legacy shape. Mono.Android (Debug, API 37.0): * versus main: -4,521,984 bytes (-10.0%), was -9.7% * versus af1e96374 raw: -90,112 bytes (-0.22%), was +28,672 Callback coverage is now 19,623 typed (65.5%), 8,527 raw UCO (28.5%), 1,818 legacy (6.1%); the legacy remainder is exactly the generic-declaring-type case. Verified: all 28,156 UCO methods are static, non-generic, declared on non-generic types and have fully blittable signatures; 0 managed calls to a UCO method across 192,336 methods. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The experimental [UnmanagedCallersOnly] callback format inherited the legacy
callback naming scheme, which names a callback `n_{ManagedName}{IDSignature}`
where `IDSignature` is the escaped JNI signature of the parameter list. That
puts a full Java signature into the #Strings heap for each of ~30,000
callbacks, twice: once for the callback and once for its `__n_*` marshaling
target. The signature was there to guarantee uniqueness, and to let a reader
recover the callback name from the `Get*Handler` connector -- but the new
format has no connector method, so nothing needs to be recovered from a name.
Callbacks are now named `n_{ManagedName}`, with a deterministic `_1`, `_2`, ...
suffix when several callbacks in one emitted type share a managed name, and the
method-specific function pointer target becomes an opaque per-type ordinal
`m0`, `m1`, ... -- it is private, referenced only by `&m{N}` from the callback
in the very same type, and never named in metadata another assembly reads.
Names are allocated by CallbackNameAllocator as a pure function of the owning
type's API definition, rather than from a counter driven by writer construction
order: a `[Register]` connector and the callback it names are emitted by
different writers, sometimes into different assemblies. For the same reason the
interface "Invoke" renaming pass in BoundInterface is hoisted so that every
writer observes final managed names.
Since a compact callback has no `Get*Handler` to name, its `[Register]`
connector stores the callback name itself, keeping any `:Owner, Assembly`
qualifier -- `n_Remove_1:...IListenerInvoker, Mono.Android, ...`. A leading
`n_` distinguishes the two forms, as no legacy connector method name can begin
with it, and JavaPeerScanner reads such a connector verbatim. A callback which
is *not* eligible for [UnmanagedCallersOnly] keeps its legacy name and its
connector method even inside a marked assembly, so the reflection-based
registration path still works for it.
The T4 helpers are renamed from `SafeInvokeMarshaled_<shape>` to
`Invoke_<shape>`, and ApiCompat is skipped while the experiment is enabled
because the connector value of `[Register]` is compared as an attribute
argument. Nothing changes for a default build.
Release Mono.Android (API 37): 38,040,064 -> 36,944,896 bytes.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Generated Java binding callbacks currently retain the legacy delegate registration shape even when the trimmable typemap registers native methods through generated
[UnmanagedCallersOnly]entry points.For each eligible callback,
Mono.Android.dllcontains all of:The trimmable typemap then emits another UCO method that forwards to
n_*.This experimental .NET 12 direction removes the delegate field, connector method, and typemap forwarding method. The binding assembly owns the UCO entry point, while a shared typed helper owns common transition and marshaling work:
The trimmable typemap's
RegisterNativesimplementation takes the external UCO method's function pointer directly. Managed code never calls the UCO method.Why this is worth exploring
Mono.Android.dllcontains approximately 30,000 generated native callbacks. Small repeated shapes therefore have multi-megabyte consequences.All four prototypes removed roughly 9–10% from the untrimmed
Mono.Android.dll. The ordering changed between Debug and Release: typed was smallest in Debug, while the non-generic ABI-shape prototype was smallest in Release.Mono.Android.dllSafeInvokeThe final isolated Release builds, which better represent shipped framework binaries, produced:
Mono.Android.dllSafeInvokeIn Release, typed is 83,968 bytes larger than raw and 127,488 bytes larger than ABI-shape. This PR selects typed as the design prototype because it expresses the desired sharing boundary most directly, not because it conclusively wins every size configuration.
In the Debug comparison, typed versus raw at equal UCO coverage showed the following shape trade-off:
Typed sharing reduces repeated IL and signatures, at the cost of 8,638 additional generic method instantiations. This trade-off, and the configuration-dependent size ordering, need more NativeAOT and Release-app investigation before the design can ship.
Implementation
Generated binding callbacks
An internal generator option enables the new format. It is disabled by default.
For supported callbacks, the generator:
[UnmanagedCallersOnly]directly onn_*.cb_*field andGet*Handlerconnector method.JniMarshalTypedhelpers.The shared helper owns:
BeginMarshalMethod;this;OnUserUnhandledException; andEndMarshalMethod.Shape names encode object/scalar arguments and void/scalar/object returns. Object returns use
IJavaObject?so covariant binding returns do not require another generic type argument.Helper naming and generation
The suffix after
SafeInvokeMarshaled_concatenates one character for each Java method argument, followed by one character for the return value:O: JNI object reference, converted to a managed peer withGetObject<T>();S: unmanaged JNI scalar, forwarded unchanged;X:void;S: unmanaged scalar, forwarded unchanged; andO: managedIJavaObject?, converted to a JNI local reference.The callback receiver (
self/TPeer) is implicit and is not encoded in the suffix.Examples:
XvoidSOOXvoidOSOOSOXvoidOSSOOOSSSSXvoidThese helpers are generated from
src/Mono.Android/Java.Interop/JniMarshalTyped.ttusing T4;JniMarshalTyped.csis the checked-in generated output. Regenerate it with:The template emits every object/scalar argument combination for arities 0–3 and the all-scalar form for arities 4–8, each with
X,S, andOreturn variants: 60 helpers in total. Shapes requiring different lifetime or cleanup semantics are intentionally not generated and retain the rawJniMarshal.SafeInvoke*path.Explicit fallbacks
The typed helper is used for 19,623 of 29,979 callbacks in API 37 (65.5%).
Another 8,527 callbacks (28.5%) are still direct UCO methods but retain the existing raw
JniMarshal.SafeInvoke*body. These include semantics that should not be centralized without further work:ICharSequence/formatted methodThe remaining 1,818 callbacks (6.1%) are declared on generic types and keep the complete legacy delegate/connector shape. CLR UCO methods cannot be declared on generic types. A future direction is a generated non-generic host/bridge inspired by #12563.
Versioned assembly format and typemap support
New-format binding assemblies carry a versioned
JavaPeerCallbackFormatassembly marker.The scanner uses the marker as a format gate and validates the actual
[UnmanagedCallersOnly]method per callback. The model records whetherRegisterNativesshould target:This permits marked and legacy binding assemblies in the same application. External callbacks are referenced with
ldftn/MemberRef; they are never called from managed IL.What the broader experiment taught us
Promising framework-size result
Removing repeated delegate registration infrastructure saves approximately 9–10% of the untrimmed framework assembly. The typed version shares more binding-specific IL than the conservative raw version and has the clearest generated shape, while the ABI-shape version currently has the smallest Release framework DLL.
Trimmed app size is not yet a demonstrated win
Five feature-verified, trimmed, Release, arm64
Maui.Controls.SampleAPKs were built with CoreCLR and the trimmable typemap. The generated callback shapes survived trimming and remained distinguishable:SafeInvoke;JniMarshalTyped;The post-trim result did not preserve the untrimmed ordering:
Mono.Android.dllWhole-APK differences are within one 4 KiB signing-alignment page and independent publishes introduced unrelated R2R nondeterminism. This experiment therefore demonstrates a large SDK/framework-size reduction, but not a final application-size reduction.
No valid performance conclusion yet
Earlier startup and callback-throughput runs were withdrawn after auditing found that some artifacts had been rebuilt without the experimental opt-in and an initial callback benchmark exercised an app-generated ACW instead of a modified framework binding callback.
The benchmark was subsequently redesigned around verified generated framework callbacks, but benchmarking was stopped before collecting final results. This PR intentionally makes no startup or throughput claim.
Compatibility and remaining work
This is a draft design prototype, not yet suitable for enabling by default.
AndroidTypeMapImplementation=trimmable. The legacy reflection registration path tries to resolve the retained connector string and cannot find the omitted connector method. The SDK must enforce this combination at build time.JniMarshalTypedhelpers and callback-format contract currently add API surface. Their final visibility, naming, and compatibility contract need design review._JniMarshal_*delegate type declarations are still needed by hand-writtenJNINativeWrapper.g.cs. Removing them is a separate follow-up.[Export], constructor, and direct-managed-dispatch callback paths still need alignment with the final format.Validation
Mono.Android.dllbuilds with the feature disabled and enabled.Maui.Controls.Samplebuilds with CoreCLR + trimmable typemap; extracted post-trim assemblies retain the expected typed callback format.