Skip to content

[TrimmableTypeMap] Emit direct RegisterNatives with UTF-8 RVA data#10988

Open
simonrozsival wants to merge 1 commit intodev/simonrozsival/trimmable-typemap-runtime-prfrom
dev/simonrozsival/register-natives-utf8
Open

[TrimmableTypeMap] Emit direct RegisterNatives with UTF-8 RVA data#10988
simonrozsival wants to merge 1 commit intodev/simonrozsival/trimmable-typemap-runtime-prfrom
dev/simonrozsival/register-natives-utf8

Conversation

@simonrozsival
Copy link
Member

Summary

Replace per-method TrimmableTypeMap.RegisterMethod() calls with a single JNI RegisterNatives call using stackalloc'd JniNativeMethod structs and compile-time UTF-8 byte data stored in RVA static fields.

What changed

  • PEAssemblyBuilder: Add GetOrAddUtf8Field() for deduplicated static fields backed by raw byte data in the PE mapped field data section. Sized value types and string content are both cached — "()V" appearing in 100 proxy types is stored once.
  • TypeMapAssemblyEmitter: Add type/member refs for JniNativeMethod, JniEnvironment.Types.RegisterNatives, JniType.PeerReference, and ReadOnlySpan<JniNativeMethod>. Rewrite EmitRegisterNatives to ldsflda deduplicated RVA fields for UTF-8 name/signature pointers, stackalloc JniNativeMethod[N], and call RegisterNatives once.
  • TrimmableTypeMap: Remove RegisterMethod (no longer called by generated code).
  • external/Java.Interop: Update submodule to c14ba04 (includes Add RegisterNatives overload for raw function pointers java-interop#1391 JniNativeMethod + raw RegisterNatives overload).

Result

Zero delegate allocations, zero string allocations, zero array allocations, one JNI call per class during native registration.

Dependencies

Copilot AI review requested due to automatic review settings March 21, 2026 12:17
@simonrozsival simonrozsival added the copilot `copilot-cli` or other AIs were used to author this label Mar 21, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the generated ACW registration codepath to use a single JniEnvironment.Types.RegisterNatives call with stackalloc’d JniNativeMethod entries and deduplicated UTF-8 data stored in PE RVA field data, removing the older per-method TrimmableTypeMap.RegisterMethod() helper.

Changes:

  • Emit RegisterNatives once per type using JniNativeMethod + ReadOnlySpan<JniNativeMethod> and method pointers.
  • Add PEAssemblyBuilder.GetOrAddUtf8Field() with mapped field data + caching to deduplicate UTF-8 literals.
  • Remove TrimmableTypeMap.RegisterMethod() and add generator tests validating new emission behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs Adds tests ensuring direct RegisterNatives usage, presence of <PrivateImplementationDetails>, and UTF-8 field dedup behavior.
src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs Removes the obsolete per-method RegisterMethod helper.
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs Reworks emitted IL to build JniNativeMethod arrays via stackalloc and call RegisterNatives once.
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs Adds mapped field data support and deduped UTF-8 RVA field creation.
external/Java.Interop Updates submodule to include JniNativeMethod and raw RegisterNatives overload.

Comment on lines +790 to 822
// stackalloc JniNativeMethod[N]
encoder.LoadConstantI4 (methodCount);
encoder.OpCode (ILOpCode.Sizeof);
encoder.Token (_jniNativeMethodRef);
encoder.OpCode (ILOpCode.Mul);
encoder.OpCode (ILOpCode.Localloc);
encoder.StoreLocal (0);

for (int i = 0; i < methodCount; i++) {
// &methods[i]
encoder.LoadLocal (0);
if (i > 0) {
encoder.LoadConstantI4 (i);
encoder.OpCode (ILOpCode.Sizeof);
encoder.Token (_jniNativeMethodRef);
encoder.OpCode (ILOpCode.Mul);
encoder.OpCode (ILOpCode.Add);
}
encoder.LoadArgument (1);
encoder.LoadString (_pe.Metadata.GetOrAddUserString (reg.JniMethodName));
encoder.LoadString (_pe.Metadata.GetOrAddUserString (reg.JniSignature));

// byte* name — ldsflda of deduplicated field
encoder.OpCode (ILOpCode.Ldsflda);
encoder.Token (nameFields [i]);

// byte* signature
encoder.OpCode (ILOpCode.Ldsflda);
encoder.Token (sigFields [i]);

// IntPtr functionPointer
encoder.OpCode (ILOpCode.Ldftn);
encoder.Token (wrapperHandle);
encoder.Call (_registerMethodRef);
encoder.Token (validRegs [i].Wrapper);

encoder.Call (_jniNativeMethodCtorRef);
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

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

JniNativeMethod initialization IL is very likely invalid due to stack type mismatches: (1) ldsflda pushes a managed byref to the RVA field’s value-type, not a byte*; passing it to a byte* parameter usually requires an explicit conversion (e.g., conv.u) or otherwise producing an unmanaged pointer. (2) Calling a value-type instance .ctor with call expects a managed byref (&JniNativeMethod) as the this argument; here methods[i] is computed from localloc as a native integer pointer, which generally won’t satisfy the required & type. A more robust pattern is to construct the struct value with newobj and then store it into methods[i] with stobj (or otherwise follow the IL pattern produced by the C# compiler for stackalloc + element assignment). This should prevent BadImageFormatException / invalid IL at runtime.

Copilot uses AI. Check for mistakes.
}

// JniObjectReference peerRef = jniType.PeerReference
encoder.LoadArgumentAddress (1);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

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

RegisterNatives(JniType jniType) passes jniType by value/reference depending on whether JniType is a reference type (it commonly is). Using LoadArgumentAddress(1) will push an argument-slot address (&), which is not a valid receiver for an instance property getter on a reference type and can produce invalid IL at runtime. Use LoadArgument(1) (and consider callvirt if the target is a reference type and you want the normal null-check semantics).

Suggested change
encoder.LoadArgumentAddress (1);
encoder.LoadArgument (1);

Copilot uses AI. Check for mistakes.
Comment on lines +856 to +858
var readOnlySpanRef = _pe.Metadata.AddTypeReference (_pe.SystemRuntimeRef,
_pe.Metadata.GetOrAddString ("System"), _pe.Metadata.GetOrAddString ("ReadOnlySpan`1"));
localSig.WriteCompressedInteger (CodedIndex.TypeDefOrRefOrSpec (readOnlySpanRef));
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

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

ReadOnlySpan type refs are being re-added in multiple places (here, plus EmitTypeReferences() and EncodeReadOnlySpanOfJniNativeMethod()), which can create duplicate TypeRef rows and makes handle usage harder to reason about. Prefer caching a single TypeReferenceHandle (e.g., _readOnlySpanRef) and reusing it consistently in the member refs, local signature encoding, and parameter signature encoding.

Copilot uses AI. Check for mistakes.
Comment on lines +209 to +218

int rva = _mappedFieldData.Count;
_mappedFieldData.WriteBytes (bytes);

var fieldHandle = Metadata.AddFieldDefinition (
FieldAttributes.Static | FieldAttributes.Assembly | FieldAttributes.HasFieldRVA | FieldAttributes.InitOnly,
Metadata.GetOrAddString ($"__utf8_{_utf8FieldCounter++}"),
Metadata.GetOrAddBlob (_sigBlob));

Metadata.AddFieldRelativeVirtualAddress (fieldHandle, rva);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

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

Field RVA data alignment can matter for PE/CLR readers. Writing raw bytes back-to-back and using the current _mappedFieldData.Count as the next RVA risks generating misaligned RVAs depending on platform/reader expectations. Consider aligning _mappedFieldData to at least 4 bytes (or the required alignment for mapped field data) before recording rva and writing each payload, padding with zeros as needed.

Copilot uses AI. Check for mistakes.
Comment on lines +189 to +190
/// The field lives on a shared <c>&lt;PrivateImplementationDetails&gt;</c> type.
/// </summary>
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

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

The comment claims the RVA field lives on <PrivateImplementationDetails>, but GetOrAddUtf8Field() calls GetOrCreateSizedType() (which adds a new nested TypeDefinition) immediately before AddFieldDefinition(). In ECMA-335 metadata, fields are associated to the most recently added TypeDefinition by table ordering, so these fields will typically end up belonging to the last created __utf8_{size} nested type, not <PrivateImplementationDetails>. Either update the documentation to match the actual metadata layout, or refactor emission so <PrivateImplementationDetails> remains the declaring type for these fields (e.g., avoid creating additional TypeDefinition rows between creating <PrivateImplementationDetails> and adding its fields, potentially by precomputing/creating sized types earlier or making sized types non-nested/top-level).

Suggested change
/// The field lives on a shared <c>&lt;PrivateImplementationDetails&gt;</c> type.
/// </summary>
/// The field is declared on an internal sized helper type (typically nested under
/// <c>&lt;PrivateImplementationDetails&gt;</c>) and is considered an implementation detail.

Copilot uses AI. Check for mistakes.
void EmitRegisterNatives (List<NativeRegistrationData> registrations,
void EmitRegisterNatives (TypeDefinitionHandle ownerType, List<NativeRegistrationData> registrations,
Dictionary<string, MethodDefinitionHandle> wrapperHandles)
{
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

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

ownerType is introduced but not used inside this method in the shown changes. If it’s not needed, remove it to avoid confusion; if it’s intended for future use (e.g., to scope UTF-8 fields or member refs), consider wiring it in now or adding a short comment explaining the planned usage.

Suggested change
{
{
// ownerType is reserved for future use (e.g., to scope UTF-8 fields or member refs).
_ = ownerType;

Copilot uses AI. Check for mistakes.
@simonrozsival simonrozsival force-pushed the dev/simonrozsival/register-natives-utf8 branch from 70f9e84 to 3134fef Compare March 21, 2026 12:25
Copy link
Member Author

@simonrozsival simonrozsival left a comment

Choose a reason for hiding this comment

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

🤖 AI Review Summary

Verdict: ⚠️ Needs Changes

Found 3 issues (0 error, 2 warning, 1 suggestion):

  • ⚠️ Performance: ReadOnlySpan\1type reference is re-created inencodeLocalsevery timeEmitRegisterNatives is called (TypeMapAssemblyEmitter.cs:857`)
  • ⚠️ Documentation: Doc comment example still shows packed-blob offset pattern but implementation uses per-string fields (TypeMapAssemblyEmitter.cs:52)
  • 💡 Code organization: ownerType parameter is unused after dedup refactor (TypeMapAssemblyEmitter.cs:751)

👍 Excellent change overall. The RVA field approach eliminates all allocations during native method registration — no delegates, no strings, no arrays. String deduplication across proxy types is a nice touch. The <PrivateImplementationDetails> pattern mirrors what the C# compiler does. Good test coverage with 3 new tests.


Review generated by android-reviewer from review guidelines.

localSig.WriteByte (0x15); // ELEMENT_TYPE_GENERICINST
localSig.WriteByte (0x11); // ELEMENT_TYPE_VALUETYPE
var readOnlySpanRef = _pe.Metadata.AddTypeReference (_pe.SystemRuntimeRef,
_pe.Metadata.GetOrAddString ("System"), _pe.Metadata.GetOrAddString ("ReadOnlySpan`1"));
Copy link
Member Author

Choose a reason for hiding this comment

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

🤖 ⚠️ Performance_pe.Metadata.AddTypeReference for ReadOnlySpan\1is called inside theencodeLocalscallback, which runs every timeEmitRegisterNatives` is invoked (once per ACW proxy type). This creates a duplicate type reference row in the metadata table for each proxy.

Consider moving this to EmitTypeReferences() where readOnlySpanRef is already created for the TypeSpec, and reuse the cached handle here.

Rule: Cache repeated accessor calls / Avoid unnecessary allocations

/// TrimmableTypeMap.RegisterMethod(jniType, "n_OnCreate", "(Landroid/os/Bundle;)V", &amp;n_OnCreate_uco_0);
/// TrimmableTypeMap.RegisterMethod(jniType, "nctor_0", "()V", &amp;nctor_0_uco);
/// JniNativeMethod* methods = stackalloc JniNativeMethod[2];
/// methods[0] = new JniNativeMethod(&amp;__utf8_natives[0], &amp;__utf8_natives[11], &amp;n_OnCreate_uco_0);
Copy link
Member Author

Choose a reason for hiding this comment

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

🤖 ⚠️ Documentation — This doc comment example still shows the packed-blob offset pattern (__utf8_natives[0], __utf8_natives[11]) but the implementation now uses per-string deduplicated fields with ldsflda. Should be updated to reflect the actual generated code, e.g.:

///     methods[0] = new JniNativeMethod(&__utf8_0, &__utf8_1, &n_OnCreate_uco_0);
///     methods[1] = new JniNativeMethod(&__utf8_2, &__utf8_1, &nctor_0_uco);

Rule: Remove stale comments (Postmortem #59)

}

void EmitRegisterNatives (List<NativeRegistrationData> registrations,
void EmitRegisterNatives (TypeDefinitionHandle ownerType, List<NativeRegistrationData> registrations,
Copy link
Member Author

Choose a reason for hiding this comment

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

🤖 💡 Code organization — The ownerType parameter is no longer used after the refactor to deduplicated fields on <PrivateImplementationDetails>. Consider removing it from the signature and the callsite at line 415.

Rule: Remove unused code (Postmortem #58)

Replace per-method TrimmableTypeMap.RegisterMethod() calls with a
single JNI RegisterNatives call using stackalloc'd JniNativeMethod
structs and compile-time UTF-8 byte data stored in RVA static fields.

Changes:
- PEAssemblyBuilder: Add AddRvaField() for static fields backed by raw
  byte data in the PE mapped field data section. Pass mappedFieldData
  to ManagedPEBuilder.
- TypeMapAssemblyEmitter: Add type/member refs for JniNativeMethod,
  JniEnvironment.Types.RegisterNatives, JniType.PeerReference, and
  ReadOnlySpan<JniNativeMethod>. Rewrite EmitRegisterNatives to pack
  all method names/signatures into one UTF-8 RVA blob per proxy type,
  stackalloc JniNativeMethod[N], and call RegisterNatives once.
- TrimmableTypeMap: Remove RegisterMethod (no longer called by
  generated code).

Result: zero delegate allocations, zero string allocations, zero
array allocations, one JNI call per class during native registration.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival force-pushed the dev/simonrozsival/register-natives-utf8 branch from 3134fef to 6b6631f Compare March 21, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

copilot `copilot-cli` or other AIs were used to author this trimmable-type-map

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants