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.
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
NoSymbolStripdefaults totruefor 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__LINKEDITSYMTAB string table (symbol names)__TEXT,__managedcode(actual R2R native code)__DATA,__data(import/fixup cells)__LINKEDITSYMTAB nlist_64 (5,376,911 x 16B)Of the 5,376,911 symbols, exactly one is external (
_RTR_HEADER_SamplesApp_r2r_o, the only one the runtime references from the generatedr2r_modules.mm). The rest areN_SECTlocal 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_STABcount is 0 and there is no__DWARFsegment, so--strip-debug-info/--strip-il-bodiesare 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:feeding
tools/msbuild/Xamarin.Shared.targets:3241:_CollectR2RFrameworksForPostProcessing(targets/Xamarin.Shared.Sdk.targets:3133) correctly adds the R2R framework to_PostProcessingItemwith the comment "Add CoreCLR/R2R framework to post-processing so they get stripped" — and the simulator condition then filters it back out. The binlog showsSkipping target "_NativeStripFiles" because it has no outputs.Note
Configurationis not part of that condition — onlySdkIsSimulator. 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 defaultNoSymbolStrip=truefor simulator whenConfiguration=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
NoSymbolStripworkaround, so newer commits will not reproduce):git clone https://github.com/unoplatform/uno.git cd uno git checkout b0b78e7e97bb81200e49c6d5665790b3eb1e070e2. 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):
3. Point the build at the preview SDK. The repo root
global.jsonsets"allowPrerelease": false, which will refuse the preview SDK with a confusing "SDK not found" error. The repo keeps the CI one atbuild/ci/net11/_global.json; MSBuild picks the nearestglobal.jsonwalking up from the working directory, so dropping it next to the project is the least invasive option:4. Build for the simulator in Release:
UnoTargetFrameworkOverriderestricts this cross-targeted repo to the one TFM (otherwise it builds several and takes much longer).UNO_DISABLE_ANALYZERS_IN_SAMPLESskips analyzers — build speed only, no effect on output.ValidateXcodeVersion=falseis 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:__LINKEDITis 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 generatedr2r_modules.mm; the other 5,376,910 areN_SECTlocals 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) vsstrip -S(remove debug symbols only), each on a copy:-xremoves 80.1% in 0.3 seconds.-Ssaves 0 bytes — there is no debug info and no__DWARFsegment, so--strip-debug-info/--strip-il-bodiesare 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:
Both exit 0; build wall time was 2:59 vs 2:55 — i.e. stripping is free. Clear
bin/Release/net11.0-iosandobj/Release/net11.0-iosbetween 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=falseexplicitly for simulator builds:Measured effect (full clean build, exit 0, build time unchanged at 2:55):
SamplesApp.r2r.apptotalReadyToRun 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.