[net11] Simulator builds never strip the composite R2R framework — 80% of a 2 GB image is the Mach-O symbol table
- Dominant language
- C#
- Stars
- 2.9k
- Forks
- 576
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 96
Description
### 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`:
```xml
true
```
feeding `tools/msbuild/Xamarin.Shared.targets:3241`:
```xml
<_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 `_PostProcessingItem`s 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](https://github.com/unoplatform/uno) 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):
```bash
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):
```bash
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:
```bash
cp build/ci/net11/_global.json src/SamplesApp/SamplesApp/global.json
```
**4. Build for the simulator in Release:**
```bash
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:
```console
$ 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:
```console
$ 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:
```bash
# 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:
```xml
false
```
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.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with tools/msbuild/Xamarin.Shared.props:125 and tools/msbuild/Xamarin.Shared.targets:3241, then trace _CollectR2RFrameworksForPostProcessing in targets/Xamarin.Shared.Sdk.targets:3133. Reproduce the Release simulator build from the issue and compare NoSymbolStrip=true with false. Done means simulator R2R frameworks are stripped without breaking the build or the required runtime symbol.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, ios
- Domain
- build-system, mobile-dev, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100