Skip to content

[generator] Prototype typed UCO binding callbacks - #12670

Draft
simonrozsival wants to merge 3 commits into
mainfrom
simonrozsival-typed-uco-marshaling
Draft

[generator] Prototype typed UCO binding callbacks#12670
simonrozsival wants to merge 3 commits into
mainfrom
simonrozsival-typed-uco-marshaling

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Sep 3, 2026

Copy link
Copy Markdown
Member

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.dll contains all of:

cb_* delegate field
Get*Handler connector method
n_* -> JniMarshal.SafeInvoke*
__n_* -> peer/argument marshaling and managed invocation

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:

[UnmanagedCallersOnly]
static int n_GetFlags (IntPtr jnienv, IntPtr native__this, IntPtr native_value)
    => JniMarshalTyped.SafeInvokeMarshaled_OS<MyType, Other, int> (
        jnienv, native__this, native_value, &__n_GetFlags);

static int __n_GetFlags (MyType self, Other? value)
    => self.GetFlags (value);

The trimmable typemap's RegisterNatives implementation takes the external UCO method's function pointer directly. Managed code never calls the UCO method.

Why this is worth exploring

Mono.Android.dll contains 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.

Debug API 37 variant Mono.Android.dll Change from baseline
Current baseline 45,262,848 B
Raw UCO + existing SafeInvoke 40,830,976 B -4,431,872 B (-9.8%)
Inline transition/marshaling 41,093,632 B -4,169,216 B (-9.2%)
Non-generic ABI-shape helpers 40,794,624 B -4,468,224 B (-9.9%)
Typed marshaling helpers (this PR) 40,740,864 B -4,521,984 B (-10.0%)

The final isolated Release builds, which better represent shipped framework binaries, produced:

Release API 37 variant Mono.Android.dll Change from baseline
Current baseline 41,879,040 B
Raw UCO + existing SafeInvoke 37,956,096 B -3,922,944 B
Typed marshaling helpers (this PR) 38,040,064 B -3,838,976 B
Inline transition/marshaling 38,223,872 B -3,655,168 B
Non-generic ABI-shape helpers 37,912,576 B -3,966,464 B

In 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:

Metric Raw UCO Typed UCO
IL bytes 8,337,025 8,075,908
Parameter rows 294,244 278,297
Standalone signature rows 23,977 15,477
MethodSpec rows 8,669 17,307
MethodDef rows 201,579 201,641

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:

  1. Emits [UnmanagedCallersOnly] directly on n_*.
  2. Omits the cb_* field and Get*Handler connector method.
  3. Calls one of 60 shape-specific JniMarshalTyped helpers.
  4. Emits a small managed function-pointer target containing the binding-specific invocation.

The shared helper owns:

  • bridge-processing synchronization;
  • BeginMarshalMethod;
  • managed peer lookup for this;
  • supported object-argument conversion;
  • invocation through a managed function pointer;
  • JNI local-reference conversion for object returns;
  • OnUserUnhandledException; and
  • EndMarshalMethod.

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:

  • argument O: JNI object reference, converted to a managed peer with GetObject<T>();
  • argument S: unmanaged JNI scalar, forwarded unchanged;
  • return X: void;
  • return S: unmanaged scalar, forwarded unchanged; and
  • return O: managed IJavaObject?, converted to a JNI local reference.

The callback receiver (self/TPeer) is implicit and is not encoded in the suffix.

Examples:

Helper suffix Java arguments Return
X none void
S none scalar
O none object
OX object void
OS object scalar
OO object object
SOX scalar, object void
OSS object, scalar scalar
OOO object, object object
SSSSX four scalars void

These helpers are generated from src/Mono.Android/Java.Interop/JniMarshalTyped.tt using T4; JniMarshalTyped.cs is the checked-in generated output. Regenerate it with:

t4 JniMarshalTyped.tt -o JniMarshalTyped.cs

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, and O return variants: 60 helpers in total. Shapes requiring different lifetime or cleanup semantics are intentionally not generated and retain the raw JniMarshal.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:

Raw-UCO fallback reason Callbacks
String parameter or return 4,537
Array/copy-back parameter or array return 1,546
Collection projection 693
ICharSequence/formatted method 551
Java-generic method with erased helper type 462
Helper arity/shape tail 311
Stream projection 197
Generic type argument in return 147
Sender parameter 45
Generic-marshaling override 24
XML parser projection 13

The 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 JavaPeerCallbackFormat assembly marker.

The scanner uses the marker as a format gate and validates the actual [UnmanagedCallersOnly] method per callback. The model records whether RegisterNatives should target:

  • an existing UCO method in the binding assembly; or
  • a generated typemap wrapper for an old connector-based assembly.

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.Sample APKs were built with CoreCLR and the trimmable typemap. The generated callback shapes survived trimming and remained distinguishable:

  • baseline: connector/delegate callbacks;
  • raw: UCO plus SafeInvoke;
  • typed: UCO plus JniMarshalTyped;
  • inline: fully inlined UCO body;
  • ABI-shape: UCO plus non-generic ABI helper.

The post-trim result did not preserve the untrimmed ordering:

Variant Signed APK Compressed assembly store Trimmed Mono.Android.dll
Baseline 33,304,195 B 7,456,014 B 1,113,600 B
Raw 33,304,195 B 7,456,145 B 1,115,136 B
Typed 33,300,099 B 7,452,310 B 1,124,352 B
Inline 33,300,099 B 7,449,744 B 1,114,112 B
ABI-shape 33,304,195 B 7,456,333 B 1,121,792 B

Whole-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.

  • New-format assemblies require 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.
  • If a marked assembly's UCO method cannot be resolved, generation must fail rather than emit a managed forwarding call to a UCO method.
  • Generic declaring types need a non-generic callback host/bridge or must continue using the measured legacy fallback.
  • The 60 JniMarshalTyped helpers and callback-format contract currently add API surface. Their final visibility, naming, and compatibility contract need design review.
  • Strings, arrays, collection projections, streams, and copy-back semantics need dedicated helper designs before increasing typed coverage.
  • NativeAOT code-size impact from the additional MethodSpec instantiations requires a controlled measurement.
  • The retained _JniMarshal_* delegate type declarations are still needed by hand-written JNINativeWrapper.g.cs. Removing them is a separate follow-up.
  • [Export], constructor, and direct-managed-dispatch callback paths still need alignment with the final format.
  • Build/app tests should cover mixed old/new binding packages, boolean/character ABI compatibility, generic declaring types, and failure diagnostics.

Validation

  • Java.Interop generator tests: 495 passed, including six new callback-format tests.
  • Trimmable typemap tests: 786 passed, including eight new scanner/model/emitter tests and a marked fixture assembly.
  • Mono.Android.dll builds with the feature disabled and enabled.
  • All 28,156 emitted UCO methods were structurally validated as static, non-generic, declared on non-generic types, and fully blittable.
  • IL scan: zero managed calls to UCO methods across 192,336 method bodies.
  • Maui.Controls.Sample builds with CoreCLR + trimmable typemap; extracted post-trim assemblies retain the expected typed callback format.

  • Useful description of why the change is necessary.
  • Links to issues fixed — experimental investigation; no issue is closed.
  • Unit tests

simonrozsival and others added 2 commits September 3, 2026 13:43
…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>
@simonrozsival simonrozsival added the do-not-merge PR should not be merged. label Sep 3, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-merge PR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant