Skip to content

[net11] Simulator builds never strip the composite R2R framework — 80% of a 2 GB image is the Mach-O symbol table #26455

Description

@MartinZikmund

Apple platform

iOS

Framework version

net11.0

Affected platform version

.NET SDK 11.0.100-preview.7.26381.103, Microsoft.iOS.Sdk.net11.0_26.5/26.5.11997-net11-p7, Xcode 26.5

Description

On a large app, a Release simulator build produces a composite ReadyToRun image where 80.7% of the file is the Mach-O local symbol table, because NoSymbolStrip defaults to true for all simulator builds.

Measured on our samples app (~276 assemblies, ~5.4M compiled methods):

SamplesApp.app/Frameworks/SamplesApp.r2r.framework/SamplesApp.r2r = 1,991,757,840 bytes

Region Bytes %
__LINKEDIT SYMTAB string table (symbol names) 1,508,951,480 75.76%
__TEXT,__managedcode (actual R2R native code) 247,417,445 12.42%
__DATA,__data (import/fixup cells) 89,925,440 4.51%
__LINKEDIT SYMTAB nlist_64 (5,376,911 x 16B) 86,030,576 4.32%
everything else 59,432,899 2.99%

Of the 5,376,911 symbols, exactly one is external (_RTR_HEADER_SamplesApp_r2r_o, the only one the runtime references from the generated r2r_modules.mm). The rest are N_SECT local symbols emitted by crossgen2 for diagnostics — DelayLoadHelperImport(...) (567 MB of names), MethodFixupSignature(...) (248 MB), GenericLookupSignature(...) (98 MB), and compiled method bodies (307 MB). Mean symbol name length is 280 bytes. N_STAB count is 0 and there is no __DWARF segment, so --strip-debug-info / --strip-il-bodies are already doing their job — this is purely the ordinary symbol table.

Composite R2R expansion itself is fine: 180,550,560 bytes of IL in, 384,315,040 bytes of stripped image out = 2.13x, inside the documented 2-3x. crossgen2 is not the problem.

The cause is tools/msbuild/Xamarin.Shared.props:125:

<!-- Disable stripping for simulator builds by default -->
<NoSymbolStrip Condition="'$(NoSymbolStrip)' == '' And '$(SdkIsSimulator)' == 'true'">true</NoSymbolStrip>

feeding tools/msbuild/Xamarin.Shared.targets:3241:

<_NativeStripItems Include="@(_PostProcessingItem)" Condition="'%(NoSymbolStrip)' != 'true'" />

_CollectR2RFrameworksForPostProcessing (targets/Xamarin.Shared.Sdk.targets:3133) correctly adds the R2R framework to _PostProcessingItem with the comment "Add CoreCLR/R2R framework to post-processing so they get stripped" — and the simulator condition then filters it back out. The binlog shows Skipping target "_NativeStripFiles" because it has no outputs.

Note Configuration is not part of that condition — only SdkIsSimulator. So a Release simulator build is never stripped.

This looks like a heuristic that predates CoreCLR/R2R: under Mono the simulator native image was small and keeping symbols was nearly free. With a composite R2R image over millions of methods it costs 1.6 GB. #25360 documents the current state ("Simulator builds remain unstripped") and #24678 measured stripping as 77.82 MB -> 41.62 MB on a MAUI sample, so the value is understood — the simulator default just hasn't been revisited at this scale.

This matters beyond disk: our CI runs iOS runtime tests on the simulator, and the 2.18 GB bundle has to be linked, code-signed, copied, zipped, uploaded and downloaded on every run.

Suggestion: make the simulator default size-aware, e.g. strip R2R framework _PostProcessingItems even on the simulator (they contain exactly one useful symbol), or don't default NoSymbolStrip=true for simulator when Configuration=Release.

Steps to Reproduce

Reproduced end-to-end on a real app. Full recipe below — no prior knowledge of the project needed. Total time ~10 min (one-time setup) + ~5 min build on an M-series Mac. Needs ~10 GB free disk.

What the app is. Uno Platform is an open-source cross-platform UI framework (WinUI API surface, targets iOS/Android/WebAssembly/desktop). Its "SamplesApp" is the framework's own kitchen-sink test app: every control sample plus the runtime-test suite in one head, ~276 assemblies, ~5.4M methods after composite R2R. It is not a contrived stress test — it is the app our CI builds and runs iOS runtime tests on for every PR. Its size is what makes the symbol-table cost visible; the same effect should appear proportionally on any large app.

1. Clone and pin a commit (pin matters — we are landing our own NoSymbolStrip workaround, so newer commits will not reproduce):

git clone https://github.com/unoplatform/uno.git
cd uno
git checkout b0b78e7e97bb81200e49c6d5665790b3eb1e070e

2. Install the pinned SDK side-by-side (the repo targets a .NET 11 preview; installing into a local dir avoids touching your machine-wide SDK):

curl -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
bash dotnet-install.sh --version 11.0.100-preview.7.26381.103 --install-dir "$PWD/.dotnet11" --no-path
export DOTNET_ROOT="$PWD/.dotnet11"
export PATH="$DOTNET_ROOT:$PATH"
dotnet workload install ios      # resolves to 26.5.11997-net11-p7

3. Point the build at the preview SDK. The repo root global.json sets "allowPrerelease": false, which will refuse the preview SDK with a confusing "SDK not found" error. The repo keeps the CI one at build/ci/net11/_global.json; MSBuild picks the nearest global.json walking up from the working directory, so dropping it next to the project is the least invasive option:

cp build/ci/net11/_global.json src/SamplesApp/SamplesApp/global.json

4. Build for the simulator in Release:

cd src/SamplesApp/SamplesApp
export DEVELOPER_DIR=/Applications/Xcode_26.6.app/Contents/Developer   # any recent Xcode
dotnet build -f net11.0-ios -c Release \
  -p:UnoTargetFrameworkOverride=net11.0-ios \
  -p:UNO_DISABLE_ANALYZERS_IN_SAMPLES=true \
  -p:ValidateXcodeVersion=false
  • UnoTargetFrameworkOverride restricts this cross-targeted repo to the one TFM (otherwise it builds several and takes much longer).
  • UNO_DISABLE_ANALYZERS_IN_SAMPLES skips analyzers — build speed only, no effect on output.
  • ValidateXcodeVersion=false is only needed if your Xcode differs from the workload's expected version (I used Xcode 26.5).

No RID is needed: the project defaults to iossimulator-x64.

5. Observe. Output lands at bin/Release/net11.0-ios/iossimulator-x64/SamplesApp.app (~2.09 GB). Verbatim from my run:

$ ls -l .../SamplesApp.app/Frameworks/SamplesApp.r2r.framework/SamplesApp.r2r
1991757840  SamplesApp.r2r

$ size -m .../SamplesApp.r2r
Segment __TEXT: 289021952
	Section __text: 113
	Section __managedcode: 247417445
	Section __const: 41602144
	total 289019702
Segment __DATA: 89927680
	Section __data: 89925440
	total 89925440
Segment __LINKEDIT: 1612824576
total 1991774208

$ nm -gU .../SamplesApp.r2r
000000000ebf5398 S _RTR_HEADER_SamplesApp_r2r_o
$ nm -gU .../SamplesApp.r2r | wc -l
       1

__LINKEDIT is 1,612,824,576 bytes — 81% of the file, against 247 MB of actual __managedcode. There is exactly one external symbol, the R2R header the runtime references from the generated r2r_modules.mm; the other 5,376,910 are N_SECT locals emitted by crossgen2 (DelayLoadHelperImport(...), MethodFixupSignature(...), GenericLookupSignature(...), method bodies), averaging 280 bytes per name.

6. Confirm it is the symbol table and not debug info — strip -x (remove local symbols) vs strip -S (remove debug symbols only), each on a copy:

$ cp SamplesApp.r2r /tmp/r2r_copy && stat -f%z /tmp/r2r_copy
1991757840
$ time xcrun strip -x /tmp/r2r_copy && stat -f%z /tmp/r2r_copy
xcrun strip -x /tmp/r2r_copy  0.06s user 0.10s system 51% cpu 0.306 total
396775840

$ cp SamplesApp.r2r /tmp/r2r_copy && xcrun strip -S /tmp/r2r_copy && stat -f%z /tmp/r2r_copy
1991757840

-x removes 80.1% in 0.3 seconds. -S saves 0 bytes — there is no debug info and no __DWARF segment, so --strip-debug-info / --strip-il-bodies are already doing their job. This is the ordinary Mach-O symbol table.

7. A/B the SDK default directly. Same tree, same command, only the property differs:

# SDK default for simulator (NoSymbolStrip=true)
dotnet build ... -p:NoSymbolStrip=true    # -> SamplesApp.r2r = 1,991,757,840 B ; .app = 2088 MB
# stripped
dotnet build ... -p:NoSymbolStrip=false   # -> SamplesApp.r2r =   384,315,040 B ; .app =  551 MB

Both exit 0; build wall time was 2:59 vs 2:55 — i.e. stripping is free. Clear bin/Release/net11.0-ios and obj/Release/net11.0-ios between runs, or MSBuild will consider the app up to date and you will measure nothing.

If a smaller standalone repro would be more convenient than cloning this repo, I'm happy to put one together — but note the effect scales with method count, so a template app will show a much less dramatic ratio.

Did you find any workaround?

Yes — setting NoSymbolStrip=false explicitly for simulator builds:

<PropertyGroup Condition="'$(RuntimeIdentifier)' != '' AND $(RuntimeIdentifier.Contains('simulator'))">
  <NoSymbolStrip>false</NoSymbolStrip>
</PropertyGroup>

Measured effect (full clean build, exit 0, build time unchanged at 2:55):

before after
SamplesApp.r2r 1,991,757,840 B 384,315,040 B (-80.7%)
.app total 2.09 GB 551 MB (-74%)

ReadyToRun stays enabled, so the startup/throughput benefit is retained — for us R2R is worth a 2.5x speedup in simulator test runs, so disabling it was not an option.

The only cost we are aware of is losing managed-frame symbolication in simulator crash reports, which is what device builds already accept.


Related: #26456 — PublishReadyToRunComposite=false (the natural alternative to evaluate here) currently fails to build on iOS.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementThe issue or pull request is an enhancementperformanceIf an issue or pull request is related to performance

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions