Summary
ReleasePropertyProjectLocator pre-evaluates project(s) in-process (via the MSBuild Object Model) before dotnet pack/dotnet publish runs, solely to read PackRelease/PublishRelease and decide whether to inject Configuration=Release. Each evaluation is created with an isolated EvaluationContext (the default), so no state is shared between them. For a solution, it evaluates every project (Parallel.ForEach over sln.SolutionProjects), each paying the full cost of SDK resolution + importing the entire .props/.targets graph from scratch.
Passing a shared EvaluationContext to these evaluations would let them reuse SDK resolver results, file-existence checks, and glob/import I/O across all projects — the dominant cost of the property (pass 1) evaluation — with no behavioral change.
Where
src/Cli/dotnet/ReleasePropertyProjectLocator.cs
private static ProjectInstance? TryGetProjectInstance(string projectPath, ReadOnlyDictionary<string, string>? globalProperties)
{
return new ProjectInstance(projectPath, globalProperties, "Current"); // isolated EvaluationContext (default)
}
and the solution fan-out in GetArbitraryProjectFromSolution:
Parallel.ForEach(sln.SolutionProjects.AsEnumerable(), (project, state) => {
var projectData = TryGetProjectInstance(projectFullPath, globalProps);
...
});
Why an EvaluationContext helps
MSBuild has no partial ("properties-only") evaluation API — reading a single property still runs the full evaluation, and the expensive part is pass 1 (properties + the SDK import graph). MSBuild does, however, expose Microsoft.Build.Evaluation.EvaluationContext which caches across evaluations:
EvaluationContext.Create(EvaluationContext.SharingPolicy.Shared) — reuses SDK resolution, file existence, and glob/import I/O across all evaluations that use it. It is documented as thread-safe for parallel evaluations.
SharingPolicy.SharedSDKCache — reuses only the SDK resolver cache (a safer subset if full file-system caching is a concern for correctness).
ProjectInstance has a constructor overload that accepts an EvaluationContext. Creating one context per GetCustomDefaultConfigurationValueIfSpecified() call and threading it through GetTargetedProject -> GetArbitraryProjectFromSolution -> TryGetProjectInstance (and the single-project path) would collapse the repeated per-project SDK-resolver + import cost into a one-time cost.
For reference, MSBuild's own ProjectGraph construction already does exactly this — it creates one EvaluationContext.Create(SharingPolicy.Shared) and reuses it across all nodes.
Suggested change
- Create a single
EvaluationContext (Shared, or SharedSDKCache if we want to be conservative about file-system caching correctness) inside GetCustomDefaultConfigurationValueIfSpecified.
- Pass it down to every
new ProjectInstance(...) in TryGetProjectInstance (and the VirtualProjectBuildingCommand path) via the ProjectInstance overload that accepts an EvaluationContext.
- Keep the existing
DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS opt-in (which evaluates only one project) as the fast path; the shared context reduces the cost of the default full-solution path.
Impact
- Single project: minor (one evaluation either way).
- Solution: potentially large. The pre-evaluation currently evaluates the entire solution just to read one property, with each project independently re-resolving the SDK and re-importing the full targets graph. Sharing the context removes that redundant per-project cost.
- No behavioral change: the same property values are read; only caching of resolver/file-system state is shared.
Notes
This cost is invisible to -bl and MSBUILDDEBUGENGINE because these are pure Object-Model evaluations (no BuildManager, no logger), so it does not show up in binlogs and is easy to overlook when profiling pack/publish.
Related env vars for context: DOTNET_CLI_DISABLE_PUBLISH_AND_PACK_RELEASE (disables the whole pre-eval) and DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS (evaluate a single project instead of the whole solution).
Summary
ReleasePropertyProjectLocatorpre-evaluates project(s) in-process (via the MSBuild Object Model) beforedotnet pack/dotnet publishruns, solely to readPackRelease/PublishReleaseand decide whether to injectConfiguration=Release. Each evaluation is created with an isolatedEvaluationContext(the default), so no state is shared between them. For a solution, it evaluates every project (Parallel.ForEachoversln.SolutionProjects), each paying the full cost of SDK resolution + importing the entire.props/.targetsgraph from scratch.Passing a shared
EvaluationContextto these evaluations would let them reuse SDK resolver results, file-existence checks, and glob/import I/O across all projects — the dominant cost of the property (pass 1) evaluation — with no behavioral change.Where
src/Cli/dotnet/ReleasePropertyProjectLocator.csand the solution fan-out in
GetArbitraryProjectFromSolution:Why an EvaluationContext helps
MSBuild has no partial ("properties-only") evaluation API — reading a single property still runs the full evaluation, and the expensive part is pass 1 (properties + the SDK import graph). MSBuild does, however, expose
Microsoft.Build.Evaluation.EvaluationContextwhich caches across evaluations:EvaluationContext.Create(EvaluationContext.SharingPolicy.Shared)— reuses SDK resolution, file existence, and glob/import I/O across all evaluations that use it. It is documented as thread-safe for parallel evaluations.SharingPolicy.SharedSDKCache— reuses only the SDK resolver cache (a safer subset if full file-system caching is a concern for correctness).ProjectInstancehas a constructor overload that accepts anEvaluationContext. Creating one context perGetCustomDefaultConfigurationValueIfSpecified()call and threading it throughGetTargetedProject->GetArbitraryProjectFromSolution->TryGetProjectInstance(and the single-project path) would collapse the repeated per-project SDK-resolver + import cost into a one-time cost.For reference, MSBuild's own
ProjectGraphconstruction already does exactly this — it creates oneEvaluationContext.Create(SharingPolicy.Shared)and reuses it across all nodes.Suggested change
EvaluationContext(Shared, orSharedSDKCacheif we want to be conservative about file-system caching correctness) insideGetCustomDefaultConfigurationValueIfSpecified.new ProjectInstance(...)inTryGetProjectInstance(and theVirtualProjectBuildingCommandpath) via theProjectInstanceoverload that accepts anEvaluationContext.DOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONSopt-in (which evaluates only one project) as the fast path; the shared context reduces the cost of the default full-solution path.Impact
Notes
This cost is invisible to
-blandMSBUILDDEBUGENGINEbecause these are pure Object-Model evaluations (noBuildManager, no logger), so it does not show up in binlogs and is easy to overlook when profilingpack/publish.Related env vars for context:
DOTNET_CLI_DISABLE_PUBLISH_AND_PACK_RELEASE(disables the whole pre-eval) andDOTNET_CLI_LAZY_PUBLISH_AND_PACK_RELEASE_FOR_SOLUTIONS(evaluate a single project instead of the whole solution).