Stream typemap fingerprints and PE output - #12602
Open
simonrozsival wants to merge 5 commits into
Open
Conversation
jonathanpeppers
force-pushed
the
simonrozsival-typemap-emission-optimizations
branch
from
August 31, 2026 15:40
8608a90 to
b21f6dd
Compare
simonrozsival
force-pushed
the
simonrozsival-typemap-optimization-retry
branch
from
August 31, 2026 18:39
f28b7c5 to
3903c05
Compare
simonrozsival
changed the base branch from
simonrozsival-typemap-emission-optimizations
to
simonrozsival-incremental-typemap-emission
August 31, 2026 18:39
simonrozsival
marked this pull request as ready for review
August 31, 2026 19:14
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Lite
Findings: 1
New issues introduced by this change (3)
| Severity | Finding |
|---|---|
src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapTypes.cs — ❌ error — GeneratedAssembly is a public record and the Content type changed from… |
|
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/FingerprintWriter.cs — FingerprintWriter.Write(...) currently silently ignores writes to the incremental… |
|
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/BlobBuilderStream.cs — 💡 suggestion — BlobBuilderStream.Read throws ArgumentOutOfRangeException(nameof(count)) for any… |
What changed in this PR
This PR optimizes the trimmable typemap generator to reduce allocations by (1) streaming both typemap fingerprints from a single model walk directly into SHA-256 and (2) returning generated PE images as a read-only, seekable stream over the existing BlobBuilder chunks (avoiding an extra contiguous MemoryStream copy). It also expands unit test coverage to lock down byte-for-byte compatibility and new stream semantics.
Changes:
- Stream content + incremental fingerprints from one walk via a new
FingerprintWriter, preserving the legacyBinaryWriterbyte stream. - Surface generated typemap assemblies as
Stream(backed byBlobBuilderStream) instead ofMemoryStreamto avoid a second buffer copy. - Add focused unit tests for pre-encoded signatures, streaming fingerprints, and
BlobBuilderStreambehavior.
| File | Description |
|---|---|
| tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs | Adds coverage for EmitBody with pre-encoded signatures. |
| tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs | Updates byte comparisons to work with Stream-typed generated assemblies. |
| tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/FingerprintWriterTests.cs | New tests pin streaming fingerprints against legacy buffered serialization. |
| tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/BlobBuilderStreamTests.cs | New tests validate BlobBuilderStream read/seek semantics and PE readability. |
| src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapTypes.cs | Changes GeneratedAssembly.Content from MemoryStream to Stream and documents stream contract. |
| src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs | Computes fingerprints once per model and uses GenerateToStream for emitted assemblies. |
| src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs | Introduces combined fingerprint computation and stream-returning generation path. |
| src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs | Accepts precomputed content fingerprint and adds EmitToStream path. |
| src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs | Splits PE serialization into a reusable SerializePE() and adds CreatePEStream(). |
| src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/MetadataHelper.cs | Replaces buffered fingerprint computation with streaming FingerprintWriter + ModelFingerprints. |
| src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/FingerprintWriter.cs | New streaming writer that reproduces BinaryWriter byte encoding into SHA-256. |
| src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/BlobBuilderStream.cs | New read-only, seekable stream over BlobBuilder segments. |
Cache fixed activation and CreateInstance signatures so repeated PE emission avoids nested signature encoder delegates while preserving emitted bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Build the cached signatures through the existing SRM encoder so the optimization stays small and preserves the established encoding path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The content fingerprint (which seeds the deterministic MVID) and the incremental-build fingerprint each walked the whole model into a BinaryWriter over a MemoryStream, then hashed the buffer. On a real Mono.Android workload those two buffered walks were the single largest allocation source in the generator. Serialise both fingerprints from one walk, streaming the fields straight into SHA-256 through a small reusable buffer. Fields shared by the two fingerprints are UTF-8 encoded once and appended to both hashes, so the byte stream seen by each hash — and therefore every fingerprint value and generated MVID — is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Emitting a typemap assembly serialised the PE image into a BlobBuilder and then copied every byte again into a MemoryStream, so each generated assembly was held twice while it was produced. Return a read-only, seekable stream over the chunks the serialiser already produced instead. GeneratedAssembly.Content is now typed as Stream, which is all the build task needs — it hashes the stream and copies it to disk through Files.CopyIfStreamChanged, so atomic last-known-good replacement is unchanged, as are the generated bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
simonrozsival
force-pushed
the
simonrozsival-typemap-optimization-retry
branch
from
August 31, 2026 19:26
3903c05 to
d5ee47f
Compare
Fail fast when a caller writes to the incremental fingerprint sink that was never requested. Dropping the write silently would produce a fingerprint that looks valid but covers less than it claims to. The incremental sink's buffer flush is now factored out to mirror the content sink's, so the two paths are symmetric. Validate the arguments of BlobBuilderStream.Read the way Stream implementations conventionally do: name the parameter that actually failed, and throw ArgumentException when the range overflows the buffer. Document why GeneratedAssembly.Content is typed as Stream rather than MemoryStream, so the narrowed member surface reads as the intentional trade for the removed buffer copy that it is. 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.



Dependent PR on top of #12599 (
simonrozsival-incremental-typemap-emission).An independent retry of optimization ideas an earlier experimental pass rejected. Every candidate was benchmarked parent-vs-candidate in alternating fresh processes against the real
Microsoft.Android.Ref.36Mono.Android.dllplus fixture workloads, on both the changed and unchanged incremental paths. Three changes cleared the bar; three did not and are not in this PR (details below).Generated output is byte-identical to the #12599 base — all typemap DLLs and Java sources hash-compared in shared and per-assembly universe modes on both workloads.
Retained
1. Cache the fixed signatures repeated for every emitted peer
CreateInstanceand the activation constructor have the same signature on every peer, but each emission rebuilt it through nested SRM signature callbacks. Cache both, and letPEAssemblyBuilderaccept a pre-encodedBlobHandle, retaining the existing encoder and IL/max-stack paths.2. Compute both typemap fingerprints in a single streaming pass
The content fingerprint (which seeds the emitted assembly's deterministic MVID) and the incremental-build fingerprint each walked the entire model into a
BinaryWriterover aMemoryStreamand then hashed the buffer. Those two buffered walks were the single largest allocation source in the generator.Both are now serialised from one walk and streamed straight into SHA-256 through a small reusable buffer (
FingerprintWriter). Fields shared by the two fingerprints are UTF-8 encoded once and appended to both hashes, so the byte stream each hash sees — and therefore every fingerprint value and generated MVID — is unchanged.3. Return generated assemblies without a second buffer
Emitting an assembly serialised the PE image into a
BlobBuilderand then copied every byte again into aMemoryStream.GeneratedAssembly.Contentis now a read-only, seekable stream over the chunks the serialiser already produced (BlobBuilderStream), typed asStreaminstead ofMemoryStream.That is all the build task needs: it hashes the stream and copies it to disk through
Files.CopyIfStreamChanged, so atomic last-known-good replacement is unchanged andGenerateTrimmableTypeMapneeded no edits.Measurements
These tables isolate changes 2 and 3. The measurement parent is this branch with only change 1 applied — i.e. the signature-encoder caching is present on both sides, so it does not contribute to the deltas below and is not separately quantified here.
Real
Microsoft.Android.Ref.36Mono.Android.dll+Java.Interop.dllas framework inputs. fixtures = the repo test fixtures (8 962 peers, 405 JCWs, 5.3 MB output); bigapp = 1 500 synthetic user ACW types overActivity/Fragment/View/Service/listener bases (10 320 peers, 1 813 JCWs, 7.2 MB output).Wall time — 12 alternating fresh-process rounds per cell, 3 timed iterations each (n = 36):
No cell regresses. Total allocated bytes (
GC.GetTotalAllocatedBytes(precise: true)) and peak committed GC bytes:The one non-improving cell is committed GC bytes on the bigapp unchanged path. It is a GC budget artifact, not retention: allocations there drop 24 MB, fewer gen0 collections run, and the collector keeps a larger gen0 budget committed. Live managed bytes after a forced full collection are 0.1 MB for both parent and candidate, and wall time on that cell improves 3.6 %.
Allocation traces (
Microsoft-Windows-DotNETRuntimeallocation sampling, bigapp changed path, 3 iterations) — top frames by sampled bytes:MetadataHelper.ComputeIncrementalFingerprint49.0 MBBlobBuilder..ctor11.5 MB (SRM-internal)MetadataHelper.WriteTypeRef27.0 MBBlobBuilder.AllocateChunk5.7 MB (SRM-internal)PEAssemblyBuilder.WritePE21.5 MBTypeMapAssemblyEmitter.EmitProxyType4.5 MBMetadataHelper.ComputeContentFingerprint13.1 MBAssemblyIndex.Build4.4 MBMetadataHelper.WriteNativeRegistration9.0 MBMergeCrossAssemblyAliases3.7 MBEvery frame changes 2 and 3 target has dropped out of the profile entirely.
Attempted and rejected
Reverted because they did not clear the bar of a reproducible wall-time win or a substantial allocation/peak-memory win at neutral wall time.
Base-constructor-chain caching. Cached generic-substitution-independent registered-constructor metadata (and decoded parameter signatures) per declaring type on
AssemblyIndex, applying generic substitutions at consumption, and hoisted the derived-constructor signature decode out of the base-constructor comparison loop. Consistently ~1 % faster in every statistic across five independent measurement sets, but that is under the run-to-run noise floor, allocations moved only 146.5 → 145.9 MB (−0.4 %), and peak committed regressed slightly because the cache retains arrays. A control build that removedCollectBaseConstructorChainoutright showed the entire path is worth only ~4.5 % (250.0 → 243.4 ms median), so the ceiling here is small.Metadata reading strategy. Compared
PEStreamOptions.Default,PrefetchMetadata,PrefetchEntireImage,FileOptions.RandomAccess, and a 1-byteFileStreambuffer on the 18.5 MBMono.Android.dll. Medians across four alternating rounds were 249.1 / 248.6 / 254.6 / 251.3 / 249.9 ms — all within noise, with identical managed allocation. No realMono.Android.dllwin, so nothing changed.Further SRM signature/IL callback allocations. Beyond the caching kept as change 1, I also tried delegate-free
BeginSignature/EndSignaturescratch encoding plus aBlobHandleoverload of the exception-regionEmitBody, converting the hottest per-method path (EmitUcoMethod, both its wrapper and callback signatures) to SRM'sout-parameterParametersoverload. Byte-identical output — but only after reordering the two signature encodings, because interning them in the opposite order changes the blob heap layout. Worth 140.4 → 138.8 MB (−1.1 %) with no wall-time change. The remaining delegate families are spread across many call sites that each carry the same interning-order hazard, so the reward did not justify the fragility.Testing
Microsoft.Android.Sdk.TrimmableTypeMap.Tests: 806 passed, 0 failed (781 on the Skip unchanged trimmable typemap assembly emission��#12599 base, +25 new).FingerprintWriterTestspin the streaming writer againstBinaryWriter/MemoryStreambyte-for-byte, including empty, non-ASCII, and values spanning the scratch and sink buffers, and re-run the previous buffered fingerprint serialisation verbatim over a model built from the real fixtures to assert both fingerprints are unchanged.BlobBuilderStreamTestscover multi-chunk reads, partial reads across chunk boundaries, re-reads after rewind (the hash-then-copy pattern the build task uses), all three seek origins, argument validation, write rejection, and that each generated assembly stream still opens as a valid PE image.GenerateTrimmableTypeMapconsumption pattern (Files.CopyIfStreamChanged+Dispose) was compiled against the newStream-typed property to confirm the task needs no change.No local SDK is available in this environment (
bin/Debug/dotnet/dotnetandbin/Release/dotnet/dotnetare absent, andmake preparecannot run here — buildingXamarin.Android.Build.Tasksfails on the Java.Interopjavacbootstrap and the missing Android SDK/aapt2 bootstrap tasks). Full-build tests —Xamarin.Android.Build.TestsandMicrosoft.Android.Sdk.TrimmableTypeMap.IntegrationTests— were therefore not run and should be covered by CI.