dotnet / dotnet/macios

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

Open
#26,455 1 comment 0 reactions 0 assignees View on GitHub
enhancement performance
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.