Skip to content

[Xamarin.Android.Build.Tasks] more LLVM IR generator build perf - #12285

Merged
jonathanpeppers merged 9 commits into
mainfrom
jonathanpeppers-build-perf-coreclr-round2
Aug 3, 2026
Merged

[Xamarin.Android.Build.Tasks] more LLVM IR generator build perf#12285
jonathanpeppers merged 9 commits into
mainfrom
jonathanpeppers-build-perf-coreclr-round2

Conversation

@jonathanpeppers

Copy link
Copy Markdown
Member

Stacked on #12280. Squash on merge.

Two more LLVM IR generator wins found by profiling a dotnet new maui -sc app (Debug, CoreCLR, arm64-v8a) with dotnet-trace against the MSBuild worker node.

1. Cache StructureMemberInfo.IsIRStruct

IsIRStruct() was an extension method that walked the member's type on every call, and it is called once per member per array element. In the trace it was 345 ms of the 550 ms spent in TypeMapGenerator.GenerateNativeAssembly:

TypeMapGenerator.GenerateNativeAssembly                       549.9
└─ LlvmIrGenerator.Generate                                   517.8
   └─ WriteGlobalVariables → WriteArrayEntries                438.6
      ├─ TypeUtilities.IsIRStruct                             345.1   <-- 63%
      │  └─ TypeUtilities.IsStructure                         282.0
      └─ WriteType                                            298.2

The answer depends only on the member, which is immutable, so it's now computed once in the StructureMemberInfo constructor.

TypeUtilities.IsIRStruct and TypeUtilities.IsStructure are entirely absent from the frame table of a trace captured after the change. .ll and .o are byte-identical.

2. $(_AndroidEmitLlvmIrComments), off by default

Comments in the generated IR are purely descriptive — llc ignores them — but they are more than half the file:

File comments on comments off (new default)
typemaps.arm64-v8a.ll 15,181,829 7,631,959 (−49.7%)
typemaps.arm64-v8a.o 4,161,824 4,161,824

New private property $(_AndroidEmitLlvmIrComments), blank (off) by default, flows into <GenerateTypeMappings/>, <GenerateNativeApplicationConfigSources/> and <GenerateCompressedAssembliesNativeSourceFiles/>. Set it to true to get the old output back when debugging the generators.

Where a comment used to terminate a line of real content, an explicit newline is written instead, and the hot paths skip building the comment string rather than just skipping the write.

GenerateTypeMappings, 12 interleaved on/off runs on the same app, raw ms:

on   733, 793, 807, 839, 841, 848
off  679, 689, 689, 696, 721, 746

Roughly 100 ms, same direction in every pair. It is not visible in wall clock (~6.1 s either way) — this machine can't resolve 100 ms end to end, so please don't read more into it than the paired ordering.

Correctness

Matched pair, same SDK binary, only the property flipped:

  • typemaps.arm64-v8a.o byte-identical — SHA256 230894AF63A583A8D2FCAC18C9AA962131A815EFCC307704B6D1E96887A837A0
  • all three string blobs byte-identical
  • 132,537 i32 values in an identical sequence
  • every non-sentinel offset still resolves to the start of a nul-terminated string in its blob; the 4,909 that don't are all the 0xFFFFFFFF sentinel

Worth calling out: the ; from: / ; to: annotations that go away by default are exactly what that offset check reads. It was run before flipping the default, and $(_AndroidEmitLlvmIrComments)=true exists so it can be run again.

Tests

  • LlvmIrGeneratorTests 9/9
  • GenerateNativeApplicationConfigSourcesTests 4/4 (runs real llc and parses the .s)
  • Microsoft.Android.Build.BaseTasks-Tests 129/129

Copilot AI review requested due to automatic review settings July 31, 2026 19:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Improves inner-loop build performance for the LLVM IR typemap/native-source generators in Xamarin.Android.Build.Tasks by (1) caching per-member structure classification and (2) allowing LLVM IR comment emission to be disabled (smaller .ll, less string-building work), while plumbing the new switch from MSBuild into the generators.

Changes:

  • Cache StructureMemberInfo.IsIRStruct at construction time and replace the previous per-call type-walk extension method.
  • Add an EmitComments / EmitLlvmIrComments switch and thread it through MSBuild → tasks → composers → LlvmIrGenerator, gating comment string creation/writes in hot paths.
  • Update MSBuild targets to pass the new property to relevant tasks (GenerateTypeMappings, GenerateNativeApplicationConfigSources, GenerateCompressedAssembliesNativeSourceFiles).

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets Wires _AndroidEmitLlvmIrComments into tasks invoked from legacy/common targets.
src/Xamarin.Android.Build.Tasks/Utilities/TypeMapGenerator.cs Adds EmitComments and passes it into the LLVM IR composer for typemap generation.
src/Xamarin.Android.Build.Tasks/Utilities/LlvmIrGenerator/TypeUtilities.cs Removes the old StructureMemberInfo.IsIRStruct(...) extension method.
src/Xamarin.Android.Build.Tasks/Utilities/LlvmIrGenerator/StructureMemberInfo.cs Computes and stores IsIRStruct once per member (perf).
src/Xamarin.Android.Build.Tasks/Utilities/LlvmIrGenerator/StructureInfo.cs Uses cached IsIRStruct when deciding whether to recurse into embedded structs.
src/Xamarin.Android.Build.Tasks/Utilities/LlvmIrGenerator/LlvmIrModule.cs Switches struct preparation recursion to cached IsIRStruct.
src/Xamarin.Android.Build.Tasks/Utilities/LlvmIrGenerator/LlvmIrInstructions.cs Gates end-of-line comment emission on the new EmitComments setting.
src/Xamarin.Android.Build.Tasks/Utilities/LlvmIrGenerator/LlvmIrGenerator.cs Introduces EmitComments plumbing and gates multiple comment hot paths.
src/Xamarin.Android.Build.Tasks/Utilities/LlvmIrGenerator/LlvmIrFunctionBody.cs Gates per-instruction comment emission on EmitComments.
src/Xamarin.Android.Build.Tasks/Utilities/LlvmIrGenerator/LlvmIrComposer.cs Adds EmitComments to composers and propagates it into LlvmIrGenerator.
src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMappings.cs Adds task parameter EmitLlvmIrComments and passes it to TypeMapGenerator.
src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs Adds task parameter EmitLlvmIrComments and propagates it to the IR generator.
src/Xamarin.Android.Build.Tasks/Tasks/GenerateCompressedAssembliesNativeSourceFiles.cs Adds task parameter EmitLlvmIrComments and propagates it to the IR generator.
src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.LlvmIr.targets Wires _AndroidEmitLlvmIrComments into GenerateTypeMappings for SDK builds.

Comment thread src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets
jonathanpeppers and others added 5 commits August 3, 2026 06:08
`WriteStringBlobArray()` emitted one LLVM IR element per byte of the type map
string blobs:

    @type_map_managed_type_names = ... constant [3035518 x i8] [
    	; 'Android.OS.AsyncTask' @ 0
    	i8 u0x41, i8 u0x6e, ...
    ]

For a hello world MAUI app that is ~3.6 million elements, producing a 52.6MB
`typemaps.arm64-v8a.ll`, which is slow both to write and for `llc` to consume.

Emit a single constant byte string instead, which is the same form the
generator already uses for constant string literals:

    @type_map_managed_type_names = ... constant [3035518 x i8] c"Android.OS.AsyncTask\00..."

Bytes are streamed through a pooled `char[]` chunk buffer to avoid millions of
single-character `TextWriter` writes.

A constant string literal has to fit on a single line, and `;` comments extend
to the end of the line, so the per-string `; 'Foo' @ 123` comments are gone.

Results for a hello world MAUI app (`dotnet new maui -sc`, Debug, median of 3):

  * `typemaps.arm64-v8a.ll`: 52.6MB -> 15.2MB
  * `GenerateTypeMappings`: 1489ms -> 1187ms
  * `CompileNativeAssembly`: 2239ms -> 203ms

The resulting `typemaps.arm64-v8a.o` is byte-for-byte identical to the one
produced before this change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f6a038cc-e09a-455d-8f08-1ea17ecfd849
ArrayPool<T>.Rent() may hand back an array larger than requested.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f6a038cc-e09a-455d-8f08-1ea17ecfd849
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f6a038cc-e09a-455d-8f08-1ea17ecfd849
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f6a038cc-e09a-455d-8f08-1ea17ecfd849
Makes the `exactly two hex digits` contract obvious at the call site.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f6a038cc-e09a-455d-8f08-1ea17ecfd849
jonathanpeppers and others added 4 commits August 3, 2026 08:14
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f6a038cc-e09a-455d-8f08-1ea17ecfd849
`dotnet-trace` of a C#-only incremental build of a MAUI app shows
`TypeUtilities.IsIRStruct()` accounting for 345 ms of the 550 ms spent
inside `TypeMapGenerator.GenerateNativeAssembly()`, 282 ms of which is
`Type.IsStructure()` walking attributes.

`IsIRStruct()` was an extension method evaluated on every call, but its
result depends only on the member's `Type` and the attributes declared on
its `MemberInfo` -- both fixed for the lifetime of the
`StructureMemberInfo`. `StructureMemberInfo` is created once per member
per structure *type* and then reused for every structure *instance*, so the
predicate was recomputed tens of thousands of times per build.

Compute it once in the constructor and expose it as a property.

Generated output is unchanged: `typemaps.arm64-v8a.ll` and
`typemaps.arm64-v8a.o` are both byte-identical (SHA256) before and after,
and after the change `TypeUtilities.IsIRStruct` / `TypeUtilities.IsStructure`
no longer appear anywhere in the trace.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fb6a74f2-a06f-4528-bc46-70fa9dee8ba9
Comments in the generated LLVM IR are purely descriptive: `llc` ignores
them entirely.  They are, however, more than half of the generated file.
For a `dotnet new maui -sc` app on a Debug CoreCLR build:

| File                     | Comments on | Comments off |
| ------------------------ | ----------- | ------------ |
| `typemaps.arm64-v8a.ll`  | 15,181,829  | 7,631,959    |
| `typemaps.arm64-v8a.o`   | 4,161,824   | 4,161,824    |

Add a `$(_AndroidEmitLlvmIrComments)` MSBuild property, blank (off) by
default, which flows into `<GenerateTypeMappings/>`,
`<GenerateNativeApplicationConfigSources/>` and
`<GenerateCompressedAssembliesNativeSourceFiles/>`.  Setting it to `true`
restores the previous output for anyone debugging the generators.

Where a comment terminated a line of real content, an explicit newline is
written instead, and the hot paths skip *building* the comment string, not
just writing it.

`GenerateTypeMappings` on the same app, interleaved on/off runs, raw ms:

    on   733, 793, 807, 839, 841, 848
    off  679, 689, 689, 696, 721, 746

That's roughly 100 ms, consistently in the same direction across all
pairs.  It is not visible in wall clock (~6.1 s either way) on this
machine.

Correctness, from a matched pair built with the same SDK binary and only
the property flipped:

* `typemaps.arm64-v8a.o` is byte-identical (SHA256
  `230894AF63A583A8D2FCAC18C9AA962131A815EFCC307704B6D1E96887A837A0`).
* All three string blobs are byte-identical.
* 132,537 `i32` values appear in an identical sequence.
* Every non-sentinel offset still resolves to the start of a
  nul-terminated string in its blob; the 4,909 that don't are all the
  `0xFFFFFFFF` sentinel.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fb6a74f2-a06f-4528-bc46-70fa9dee8ba9
…nts are off

Review feedback on the previous commit: two call sites write indentation
*before* calling into the comment writers, so with
`$(_AndroidEmitLlvmIrComments)` off they emitted an indent-only line
instead of nothing at all.

* `LlvmIrFunctionBodyComment` is an item whose entire content is a
  comment.  `LlvmIrFunctionBodyItem.Write()` writes the indent, then the
  comment, then unconditionally terminates the line, so the item turned
  into a blank line.  Add a `IsCommentOnly` hook so such items are
  skipped outright rather than writing an empty line.
* `WriteGlobalVariables()` writes a newline and the current indent before
  a group delimiter's comment.  Fold `EmitComments` into the surrounding
  condition so the whole block is skipped.

Note this is *not* the same as making `WriteCommentLine()` always
terminate the line, which was the other option considered.  The vast
majority of comments are on lines of their own, so that would turn
~182,000 comment lines into ~182,000 blank lines and give back most of
the size win.

Also fix a "Constrict" -> "Construct" typo in a nearby exception message.

The generated type maps do not exercise either path, so this is
defensive: `typemaps.arm64-v8a.ll` is byte-identical to before this
commit both on (SHA256 `A67E964C...B3E2ED`) and off (`E0A1F539...D152BD`),
and `typemaps.arm64-v8a.o` remains identical between on and off
(`230894AF...A837A0`).  Neither `.ll` contains any whitespace-only line.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fb6a74f2-a06f-4528-bc46-70fa9dee8ba9
@jonathanpeppers
jonathanpeppers force-pushed the jonathanpeppers-build-perf-coreclr-round2 branch from b837f6f to fa5b225 Compare August 3, 2026 13:20
Base automatically changed from jonathanpeppers-llvm-ir-cstring-blobs to main August 3, 2026 19:04
@jonathanpeppers jonathanpeppers added the ready-to-review This PR is ready to review/merge, I think any CI failures are just flaky (ignorable). label Aug 3, 2026
@jonathanpeppers
jonathanpeppers merged commit 9a2c6ee into main Aug 3, 2026
44 checks passed
@jonathanpeppers
jonathanpeppers deleted the jonathanpeppers-build-perf-coreclr-round2 branch August 3, 2026 23:44
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

ready-to-review This PR is ready to review/merge, I think any CI failures are just flaky (ignorable).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants