Skip to content

Add opt-in ReadyToRun bootstrap for startup perf testing#13302

Open
JanProvaznik wants to merge 2 commits intodotnet:mainfrom
JanProvaznik:feature/readytorun-bootstrap
Open

Add opt-in ReadyToRun bootstrap for startup perf testing#13302
JanProvaznik wants to merge 2 commits intodotnet:mainfrom
JanProvaznik:feature/readytorun-bootstrap

Conversation

@JanProvaznik
Copy link
Member

@JanProvaznik JanProvaznik commented Feb 27, 2026

Summary

Adds an opt-in \OverlayReadyToRunBootstrap\ target that crossgen2-compiles MSBuild DLLs and overlays them into the bootstrap SDK folder. This is useful to get a bootstrap with closer performance characteristics to the real product

Usage: \\�uild.cmd -c Release /p:PublishReadyToRun=true\\

What it does

  1. After the normal bootstrap is created, restores with the target RID
  2. Runs \dotnet publish --no-build\ to invoke crossgen2 on the already-built DLLs (no recompilation)
  3. Copies 16 R2R DLLs into the bootstrap SDK folder

Performance

Scenario Normal With R2R Overhead
Incremental 17s 28s +11s
Clean 98s 108s +10s

The ~10s overhead is pure crossgen2 time. MSBuild DLLs grow ~2× (e.g. Microsoft.Build.dll: 3.2MB → 7.2MB) confirming AOT compilation.

Scope

  • 16 MSBuild-specific DLLs are R2R'd (Microsoft.Build, Framework, Tasks, Utilities, MSBuild, StringTools, + System.* deps)
  • SDK-owned DLLs are already R2R'd by the .NET SDK
  • RID auto-detected via \NETCoreSdkRuntimeIdentifier\
  • Without /p:PublishReadyToRun=true, build is completely unchanged

Testing

  • Verified: \�uild.cmd -c Release /p:PublishReadyToRun=true\ ✅
  • Verified: \�uild.cmd -c Release\ (no regression) ✅
  • Verified: bootstrap DLLs are 2× larger (R2R confirmed) ✅

JanProvaznik and others added 2 commits February 27, 2026 09:30
Add OverlayReadyToRunBootstrap target that crossgen2-compiles MSBuild
DLLs and overlays them into the bootstrap SDK folder.

Usage: build.cmd -c Release /p:PublishReadyToRun=true

The target runs after the normal BootstrapNetCore, does a RID-specific
restore, then publishes with --no-build to skip recompilation and only
run crossgen2 on the already-built DLLs. This adds ~10s to the build
(pure crossgen2 time, no double compilation). The RID is auto-detected
via NETCoreSdkRuntimeIdentifier.

16 MSBuild-specific DLLs are R2R'd (Microsoft.Build.dll, Framework,
Tasks, Utilities, MSBuild.dll, StringTools, plus System.* dependencies).
SDK-owned DLLs are already R2R'd by the .NET SDK itself.

Without /p:PublishReadyToRun=true, build behavior is unchanged.

Co-authored-by: Copilot <[email protected]>
@JanProvaznik JanProvaznik requested a review from a team as a code owner February 27, 2026 08:30
Copilot AI review requested due to automatic review settings February 27, 2026 08:30
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in bootstrap step to produce a ReadyToRun (R2R) overlay of MSBuild assemblies for startup perf testing, and documents how to use it.

Changes:

  • Add OverlayReadyToRunBootstrap target that runs RID-specific restore + dotnet publish --no-build with PublishReadyToRun=true, then copies published DLLs into the bootstrap SDK.
  • Document the new /p:PublishReadyToRun=true bootstrap flow and suggested benchmarking invocation.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
eng/BootStrapMsBuild.targets Introduces the opt-in R2R overlay target executed after BootstrapNetCore.
documentation/wiki/Bootstrap.md Documents the R2R bootstrap option and benchmarking guidance.

Comment on lines +274 to +284
</PropertyGroup>

<Message Text="Publishing MSBuild with ReadyToRun for $(NETCoreSdkRuntimeIdentifier)..." Importance="High" />

<!-- First, restore with the target RID so the assets file has the net10.0/rid target. -->
<Exec Command="&quot;$(DotNetTool)&quot; restore &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -p:TargetFramework=$(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier)" />

<!-- Publish with no-build to skip recompilation and only run crossgen2 on already-built DLLs.
AppendRuntimeIdentifierToOutputPath=false lets publish find the existing non-RID build output.
BuildProjectReferences=false avoids rebuilding dependency projects. -->
<Exec Command="&quot;$(DotNetTool)&quot; publish &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -c $(Configuration) -f $(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier) --no-build -p:PublishReadyToRun=true -p:ErrorOnDuplicatePublishOutputFiles=false -p:AppendRuntimeIdentifierToOutputPath=false -p:BuildProjectReferences=false -p:PublishDir=&quot;$(_R2RPublishDir)&quot;" />
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$(NETCoreSdkRuntimeIdentifier) is referenced for the RID, but it is not defined anywhere in this repo (only used here). If it’s empty, both dotnet restore -r and dotnet publish -r will fail due to a missing argument. Consider introducing an internal property (e.g., _R2RRid) that falls back to a known MSBuild/SDK-provided RID property (such as $(RuntimeIdentifier) when set) or computes one from OS/arch, and fail with a clear message if a RID can’t be determined.

Suggested change
</PropertyGroup>
<Message Text="Publishing MSBuild with ReadyToRun for $(NETCoreSdkRuntimeIdentifier)..." Importance="High" />
<!-- First, restore with the target RID so the assets file has the net10.0/rid target. -->
<Exec Command="&quot;$(DotNetTool)&quot; restore &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -p:TargetFramework=$(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier)" />
<!-- Publish with no-build to skip recompilation and only run crossgen2 on already-built DLLs.
AppendRuntimeIdentifierToOutputPath=false lets publish find the existing non-RID build output.
BuildProjectReferences=false avoids rebuilding dependency projects. -->
<Exec Command="&quot;$(DotNetTool)&quot; publish &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -c $(Configuration) -f $(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier) --no-build -p:PublishReadyToRun=true -p:ErrorOnDuplicatePublishOutputFiles=false -p:AppendRuntimeIdentifierToOutputPath=false -p:BuildProjectReferences=false -p:PublishDir=&quot;$(_R2RPublishDir)&quot;" />
<!-- Resolve the RID for ReadyToRun publishing.
Prefer an explicitly provided NETCoreSdkRuntimeIdentifier, otherwise fall back to RuntimeIdentifier. -->
<_R2RRid Condition="'$(_R2RRid)' == '' and '$(NETCoreSdkRuntimeIdentifier)' != ''">$(NETCoreSdkRuntimeIdentifier)</_R2RRid>
<_R2RRid Condition="'$(_R2RRid)' == '' and '$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</_R2RRid>
</PropertyGroup>
<!-- Fail fast with a clear message if we cannot determine a runtime identifier. -->
<Error Condition="'$(_R2RRid)' == ''"
Text="OverlayReadyToRunBootstrap requires a runtime identifier. Set 'RuntimeIdentifier' or 'NETCoreSdkRuntimeIdentifier' when invoking this build." />
<Message Text="Publishing MSBuild with ReadyToRun for $(_R2RRid)..." Importance="High" />
<!-- First, restore with the target RID so the assets file has the net10.0/rid target. -->
<Exec Command="&quot;$(DotNetTool)&quot; restore &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -p:TargetFramework=$(LatestDotNetCoreForMSBuild) -r $(_R2RRid)" />
<!-- Publish with no-build to skip recompilation and only run crossgen2 on already-built DLLs.
AppendRuntimeIdentifierToOutputPath=false lets publish find the existing non-RID build output.
BuildProjectReferences=false avoids rebuilding dependency projects. -->
<Exec Command="&quot;$(DotNetTool)&quot; publish &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -c $(Configuration) -f $(LatestDotNetCoreForMSBuild) -r $(_R2RRid) --no-build -p:PublishReadyToRun=true -p:ErrorOnDuplicatePublishOutputFiles=false -p:AppendRuntimeIdentifierToOutputPath=false -p:BuildProjectReferences=false -p:PublishDir=&quot;$(_R2RPublishDir)&quot;" />

Copilot uses AI. Check for mistakes.
Comment on lines +272 to +288
<PropertyGroup>
<_R2RPublishDir>$(ArtifactsBinDir)MSBuild.ReadyToRun\</_R2RPublishDir>
</PropertyGroup>

<Message Text="Publishing MSBuild with ReadyToRun for $(NETCoreSdkRuntimeIdentifier)..." Importance="High" />

<!-- First, restore with the target RID so the assets file has the net10.0/rid target. -->
<Exec Command="&quot;$(DotNetTool)&quot; restore &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -p:TargetFramework=$(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier)" />

<!-- Publish with no-build to skip recompilation and only run crossgen2 on already-built DLLs.
AppendRuntimeIdentifierToOutputPath=false lets publish find the existing non-RID build output.
BuildProjectReferences=false avoids rebuilding dependency projects. -->
<Exec Command="&quot;$(DotNetTool)&quot; publish &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -c $(Configuration) -f $(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier) --no-build -p:PublishReadyToRun=true -p:ErrorOnDuplicatePublishOutputFiles=false -p:AppendRuntimeIdentifierToOutputPath=false -p:BuildProjectReferences=false -p:PublishDir=&quot;$(_R2RPublishDir)&quot;" />

<ItemGroup>
<_R2RPublishedDlls Include="$(_R2RPublishDir)*.dll" />
</ItemGroup>
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The publish directory is reused ($(_R2RPublishDir)), and _R2RPublishedDlls is a wildcard over *.dll. If a previous run left extra DLLs in that folder, they’ll be copied into the bootstrap even if the current publish no longer produces them. Deleting/cleaning $(_R2RPublishDir) (and recreating it) before publishing would avoid stale overlays.

Copilot uses AI. Check for mistakes.
<!-- Publish with no-build to skip recompilation and only run crossgen2 on already-built DLLs.
AppendRuntimeIdentifierToOutputPath=false lets publish find the existing non-RID build output.
BuildProjectReferences=false avoids rebuilding dependency projects. -->
<Exec Command="&quot;$(DotNetTool)&quot; publish &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -c $(Configuration) -f $(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier) --no-build -p:PublishReadyToRun=true -p:ErrorOnDuplicatePublishOutputFiles=false -p:AppendRuntimeIdentifierToOutputPath=false -p:BuildProjectReferences=false -p:PublishDir=&quot;$(_R2RPublishDir)&quot;" />
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This target runs an explicit dotnet restore and then dotnet publish without --no-restore, so publish will typically restore again. Adding --no-restore to the publish invocation would avoid the redundant restore and make the behavior depend solely on the preceding restore step.

Suggested change
<Exec Command="&quot;$(DotNetTool)&quot; publish &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -c $(Configuration) -f $(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier) --no-build -p:PublishReadyToRun=true -p:ErrorOnDuplicatePublishOutputFiles=false -p:AppendRuntimeIdentifierToOutputPath=false -p:BuildProjectReferences=false -p:PublishDir=&quot;$(_R2RPublishDir)&quot;" />
<Exec Command="&quot;$(DotNetTool)&quot; publish &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -c $(Configuration) -f $(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier) --no-restore --no-build -p:PublishReadyToRun=true -p:ErrorOnDuplicatePublishOutputFiles=false -p:AppendRuntimeIdentifierToOutputPath=false -p:BuildProjectReferences=false -p:PublishDir=&quot;$(_R2RPublishDir)&quot;" />

Copilot uses AI. Check for mistakes.
</ItemGroup>

<Copy SourceFiles="@(_R2RPublishedDlls)"
DestinationFolder="$(InstallDir)sdk\$(BootstrapSdkVersion)\" />
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overlay Copy doesn’t set SkipUnchangedFiles="true", which can cause unnecessary file writes into the bootstrap SDK folder on repeated runs. Setting SkipUnchangedFiles would reduce IO overhead and improve incremental perf for this opt-in step.

Suggested change
DestinationFolder="$(InstallDir)sdk\$(BootstrapSdkVersion)\" />
DestinationFolder="$(InstallDir)sdk\$(BootstrapSdkVersion)\"
SkipUnchangedFiles="true" />

Copilot uses AI. Check for mistakes.
build.cmd -c Release /p:PublishReadyToRun=true
```

This runs the normal build, then crossgen2-compiles the 16 MSBuild-specific DLLs (Microsoft.Build.dll, Framework, Tasks, Utilities, etc.) and overlays them into the bootstrap. The R2R binaries are ~2× larger but reduce JIT work at startup. The RID is auto-detected from the current platform.
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs state the RID is “auto-detected from the current platform”, but the implementation relies on $(NETCoreSdkRuntimeIdentifier) being set. Since that property isn’t defined in this repo, either clarify in the docs that the RID property must be provided, or update the build logic to compute/fallback the RID so the statement is accurate.

Suggested change
This runs the normal build, then crossgen2-compiles the 16 MSBuild-specific DLLs (Microsoft.Build.dll, Framework, Tasks, Utilities, etc.) and overlays them into the bootstrap. The R2R binaries are ~2× larger but reduce JIT work at startup. The RID is auto-detected from the current platform.
This runs the normal build, then crossgen2-compiles the 16 MSBuild-specific DLLs (Microsoft.Build.dll, Framework, Tasks, Utilities, etc.) and overlays them into the bootstrap. The R2R binaries are ~2× larger but reduce JIT work at startup. The RID used for R2R is taken from the `NETCoreSdkRuntimeIdentifier` property; if that property is not set, you must supply an explicit RID (for example, via `/p:RuntimeIdentifier=<rid>`).

Copilot uses AI. Check for mistakes.
<!-- Publish with no-build to skip recompilation and only run crossgen2 on already-built DLLs.
AppendRuntimeIdentifierToOutputPath=false lets publish find the existing non-RID build output.
BuildProjectReferences=false avoids rebuilding dependency projects. -->
<Exec Command="&quot;$(DotNetTool)&quot; publish &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -c $(Configuration) -f $(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier) --no-build -p:PublishReadyToRun=true -p:ErrorOnDuplicatePublishOutputFiles=false -p:AppendRuntimeIdentifierToOutputPath=false -p:BuildProjectReferences=false -p:PublishDir=&quot;$(_R2RPublishDir)&quot;" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not wrong, this is a bin-clash. One of the less interesting one but still. The base intermediate and intermediate output paths clash during restore. Restore runs again with the additional set of inputs. This results in the project.assets.json file getting overwritten. If you don't need to re-restore, consider passing in the ´--no-restore` flag. FWIW our skill would detect that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we invoking dotnet directly here at all instead of using normal MSBuild Task invocations? Invoking builds of any kinda via exec should be considered a smell.

@rainersigwald
Copy link
Member

My first thought on seeing this is "can we defer to using the VMR for this?" Especially since I wouldn't want drift between how-we-invoke-crossgen and how-the-official-SDK-build-does-it to cause us confusion.

Comment on lines +278 to +279
<!-- First, restore with the target RID so the assets file has the net10.0/rid target. -->
<Exec Command="&quot;$(DotNetTool)&quot; restore &quot;$(RepoRoot)src\MSBuild\MSBuild.csproj&quot; -p:TargetFramework=$(LatestDotNetCoreForMSBuild) -r $(NETCoreSdkRuntimeIdentifier)" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we have RuntimeIdentifers set for all of the RIDs we want to target we don't have to do separate restores - as long as we also have PublishReadyToRun set at Restore-time. For the actual builds we can toggle PublishReadyToRun however we need, the idea is just that the Restore becomes as 'maximal' as possible.

@baronfel
Copy link
Member

My first thought on seeing this is "can we defer to using the VMR for this?" Especially since I wouldn't want drift between how-we-invoke-crossgen and how-the-official-SDK-build-does-it to cause us confusion.

SDK official builds do weird invocations of crossgen anyway - they have an SDK-layout specific Task + Targets that call the crossgen DLL on specific lists of assemblies: https://github.com/dotnet/sdk/blob/3e0e00aab9ba5b3d2de2e58edb1dad2a31eb5f8d/src/Layout/redist/targets/Crossgen.targets#L153-L187

that's sufficiently nonstandard that I'm not sure we can really use it?

@rainersigwald
Copy link
Member

that's sufficiently nonstandard that I'm not sure we can really use it?

That's kinda what I'm saying--if we can't simulate the actual way the product is constructed, are we getting the benefits out of doing it differently here?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants