[Xamarin.Android.Build.Tasks] memoize LlvmIrGenerator basic type writes - #12286
Conversation
`LlvmIrGenerator.WriteType (context, Type, ...)` has a fast path for basic scalar types. Its own comment notes it "runs millions of times for a typical application" -- a MAUI app's `typemaps.arm64-v8a.ll` is 7.6MB / 232k lines with 16,567 typemap entries, and every scalar member of every array entry goes through it. Each call did three dictionary lookups (`basicTypeMap.ContainsKey`, then `MapToIRType` -> `IsNativePointer` + `basicTypeMap.TryGetValue`) plus a `LlvmTypeInfo` allocation, all of which produce the same result for a given `Type`. `MapToIRType` is documented as target-independent, and a generator instance has a fixed `target`, so the pair can be memoized per generator instance. Steady state is now one dictionary lookup and no allocation. Structural evidence, `dotnet-trace` of a MAUI source-change rebuild (inclusive ms, same machine, matched pair): | frame | before | after | | ---------------------------------- | ------ | ----- | | `GetActualType` | 303.9 | (gone)| | `MapToIRType` | 307.9 | 3.8 | | `WriteType (context, Type, ...)` | 305.9 | 58.2 | | `LlvmIrGenerator.Generate` | 379.9 | 286.8 | | `TypeMapGenerator.GenerateNativeAssembly` | 429.8 | 330.5 | All 446 generated outputs (368 `.java`, 10 `.ll` incl. both typemaps, `AndroidManifest.xml` x2, `acw-map.txt`) are byte-identical before and after. Co-authored-by: Copilot App <[email protected]>
There was a problem hiding this comment.
Pull request overview
This PR optimizes the LLVM IR generation hot path in LlvmIrGenerator.WriteType by memoizing the IR type string and associated LlvmTypeInfo for basic scalar managed types, reducing repeated lookups and per-value allocations during typemap generation.
Changes:
- Introduces a per-generator cache for basic scalar type write metadata to avoid repeated type mapping work in
WriteType. - Updates
WriteType’s fast path to reuse cached(IRType, LlvmTypeInfo)when emitting basic scalar types.
Suppressed comments (1)
src/Xamarin.Android.Build.Tasks/Utilities/LlvmIrGenerator/LlvmIrGenerator.cs:526
- 💡 suggestion: After the cache miss, this branch still does a
ContainsKeylookup and then callsGetIRType, which redoesbasicTypeMap.TryGetValueinternally. You can collapse this into a singleTryGetValueand avoid the extra lookup +GetActualTypework for the first encounter of each basic scalar type.
if (basicTypeMap.ContainsKey (type)) {
string basicIRType = GetIRType (context, type, out ulong basicSize, out bool basicIsPointer);
typeInfo = new LlvmTypeInfo (
isPointer: basicIsPointer,
isAggregate: false,
| sealed class BasicTypeWriteInfo | ||
| { | ||
| public readonly string IRType; | ||
| public readonly LlvmTypeInfo TypeInfo; | ||
|
|
||
| public BasicTypeWriteInfo (string irType, LlvmTypeInfo typeInfo) | ||
| { | ||
| IRType = irType; | ||
| TypeInfo = typeInfo; | ||
| } | ||
| } |
|
Recording a known redundancy found while profiling The same
Scale on a
So ~1.58 MB of XML is parsed twice. The obvious fix — a process-wide static cache keyed on path + mtime — is not safe here. MSBuild node reuse is on by default for customers, so a static would persist across builds in a reused node and would have to get the invalidation exactly right; that class of cache is a known source of stale-output bugs. Sharing the parse would otherwise require merging or coupling two separate public tasks. Worth knowing about; not worth fixing speculatively. |
Memoizes the basic-scalar fast path in
LlvmIrGenerator.WriteType. The method's own comment says it "runs millions of times for a typical application" — a MAUI app'stypemaps.arm64-v8a.llis 7.6 MB / 232k lines with 16,567 typemap entries, and every scalar member of every array entry lands here.Each call did 3 dictionary lookups (
basicTypeMap.ContainsKey→MapToIRType→IsNativePointer+basicTypeMap.TryGetValue) plus anLlvmTypeInfoallocation. All of it is a pure function of theType:MapToIRTypeis documented as target-independent, and a generator instance has a fixedtarget, so the(IRType, LlvmTypeInfo)pair is cached per generator instance. Steady state: 1 lookup, 0 allocations.Structural evidence —
dotnet-trace, MAUI source-change rebuild, matched pairInclusive ms inside
GenerateTypeMappings:GetActualTypeMapToIRTypeWriteType (context, Type, …)WriteStructureValueLlvmIrGenerator.GenerateTypeMapGenerator.GenerateNativeAssemblyGetActualTypeno longer appears in the trace's frame table at all — proof the call was eliminated, independent of timing.Wall clock — honest read: below the noise floor
Interleaved A/B, 6 pairs, source-change rebuild of a
dotnet new maui -scapp,-nodeReuse:false.GenerateTypeMappingstask, raw ms:Pairs 5–6 were hit by unrelated machine load. The ranges fully overlap; I am not claiming a wall-clock win. The trace numbers are inclusive time under a profiler and overstate the real saving. The defensible claim is the structural one: ~200k dictionary lookups and ~200k allocations removed per build.
Correctness
446 generated outputs hashed (SHA256), sorted, diffed against a matched baseline captured immediately before the binary swap: all identical.
.javaJava Callable Wrappers.ll(bothtypemaps.*,environment.*,jni_remap.*,marshal_methods.*,compressed_assemblies.*)AndroidManifest.xml×2,acw-map.txtTests:
Microsoft.Android.Build.BaseTasks-Tests129/129 pass;Xamarin.Android.Build.Tests --filter LlvmIr9/9 pass.Squash on merge.