-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Add opt-in partial (stop-after-pass) project evaluation #14290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| # Partial (stop-after-pass) project evaluation | ||
|
|
||
| Tracking issue: [dotnet/msbuild#14288](https://github.com/dotnet/msbuild/issues/14288) | ||
| Related SDK consumer: [dotnet/sdk#55193](https://github.com/dotnet/sdk/issues/55193) | ||
|
|
||
| ## Motivation | ||
|
|
||
| Some callers only need data produced by an early evaluation pass. The most common example is | ||
| reading a single property (for example the SDK's `ReleasePropertyProjectLocator` reads | ||
| `PublishRelease`/`PackRelease`), which today forces a **full** evaluation of the project — all | ||
| passes, including item globbing, using-task registration, and target registration. | ||
|
|
||
| MSBuild evaluation runs a fixed sequence of passes: | ||
|
|
||
| | Pass | Work | | ||
| | ---- | ---- | | ||
| | 0 | Initial properties (environment, global, toolset, reserved) | | ||
| | 1 | Properties + imports (also gathers item/item-definition/using-task/target *elements* and `InitialTargets`) | | ||
| | 2 | Item definitions | | ||
| | 3 / 3.1 | Items (includes wildcard/glob expansion) | | ||
| | 4 | Using-tasks (task registry) | | ||
| | 5 | Targets (registration, `DefaultTargets`, before/after maps) | | ||
|
|
||
| Property values are final after pass 1: properties cannot depend on items (item references inside | ||
| property values expand to empty even in a full evaluation), so a stop-at-properties evaluation | ||
| produces property values identical to a full evaluation. | ||
|
|
||
| On a file-heavy project (500 source files, ~200 properties, 50 targets), stopping after the | ||
| properties pass measured roughly a **46%** reduction in per-evaluation wall-clock versus a full | ||
| evaluation (Debug engine build; relative comparison). Passes 2–5 dominate the remainder, with item | ||
| globbing being the largest single contributor as source-file count grows. | ||
|
|
||
| ## API | ||
|
|
||
| A new opt-in knob on `ProjectOptions` selects how far evaluation proceeds: | ||
|
|
||
| ```csharp | ||
| namespace Microsoft.Build.Evaluation | ||
| { | ||
| public enum ProjectEvaluationStage | ||
| { | ||
| Properties, // stop after pass 1 | ||
| ItemDefinitions, // stop after pass 2 | ||
| Items, // stop after pass 3 / 3.1 | ||
| UsingTasks, // stop after pass 4 | ||
| Full = int.MaxValue // default: run every pass (pass 5) | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ```csharp | ||
| public class ProjectOptions | ||
| { | ||
| public ProjectEvaluationStage EvaluationStage { get; set; } = ProjectEvaluationStage.Full; | ||
| } | ||
| ``` | ||
|
|
||
| The stage flows through the existing factory methods: | ||
|
|
||
| - `ProjectInstance.FromFile(path, options)` / `ProjectInstance.FromProjectRootElement(xml, options)` | ||
| - `Project.FromFile(path, options)` / `Project.FromProjectRootElement(xml, options)` / `Project.FromXmlReader(reader, options)` | ||
|
|
||
| Both `Project.EvaluationStage` and `ProjectInstance.EvaluationStage` report the stage the object was | ||
| evaluated to. | ||
|
|
||
| Example: | ||
|
|
||
| ```csharp | ||
| var options = new ProjectOptions | ||
| { | ||
| EvaluationStage = ProjectEvaluationStage.Properties, | ||
| EvaluationContext = sharedContext, // complements partial evaluation; see sdk#55193 | ||
| }; | ||
|
|
||
| ProjectInstance instance = ProjectInstance.FromFile(projectPath, options); | ||
| string value = instance.GetPropertyValue("PublishRelease"); // fast: only passes 0-1 ran | ||
| ``` | ||
|
|
||
| ## Behavior of a partially-evaluated object | ||
|
|
||
| - **Properties are always valid** for any stage ≥ `Properties` (`GetProperty`, `GetPropertyValue`, | ||
| `Properties`, `GlobalProperties`). `InitialTargets` is also available from `Properties` onward | ||
| because it is computed during pass 1. | ||
| - **Reading not-yet-computed state fails fast.** Members that expose state from a later pass throw | ||
| `InvalidOperationException` naming the member and the stage the object reached. Guarded members | ||
| include `ItemDefinitions` (available from `ItemDefinitions` onward), `Items`, `GetItems`, | ||
| `ItemsIgnoringCondition`, `AllEvaluatedItems`, `Targets`, and `DefaultTargets` (and their | ||
| `ProjectInstance` equivalents). | ||
| - **A partial `ProjectInstance` cannot be built.** Constructing a `BuildRequestData` from a partial | ||
| instance throws `InvalidOperationException`; a build requires a full evaluation. | ||
|
|
||
| The default (`Full`) is unchanged, so existing callers are unaffected. | ||
|
|
||
| ## Evaluation caching | ||
|
|
||
| `ProjectCollection` caches loaded `Project`s keyed on (path, global properties, tools version) — the | ||
| evaluation stage is not part of the key. To avoid serving stale partial state: | ||
|
|
||
| - A cached project satisfies a request only if `cachedStage >= requestedStage`. | ||
| - `ProjectCollection.LoadProject` requests `Full`. If the only cached project for a key was | ||
| partially evaluated, it is **upgraded in place** (re-evaluated to `Full`) and returned, rather than | ||
| returning partial state or creating a duplicate cache entry. | ||
| - Calling the public `Project.ReevaluateIfNecessary()` on a partial project upgrades it to `Full` | ||
| (a partial evaluation leaves the project non-dirty, so the re-evaluation is forced). | ||
|
|
||
| ## Relationship to `EvaluationContext` | ||
|
|
||
| Partial evaluation and a shared `EvaluationContext` are complementary levers. A shared context | ||
| caches file-system probes and SDK resolution across projects; partial evaluation skips whole passes | ||
| within each project. Batch scenarios that read one property from many projects (such as the SDK | ||
| release-property locator) benefit from using both together. |
227 changes: 227 additions & 0 deletions
227
src/Build.UnitTests/Evaluation/PartialEvaluation_Tests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System; | ||
| using System.IO; | ||
| using System.Xml; | ||
|
|
||
| using Microsoft.Build.Construction; | ||
| using Microsoft.Build.Definition; | ||
| using Microsoft.Build.Evaluation; | ||
| using Microsoft.Build.Execution; | ||
| using Shouldly; | ||
| using Xunit; | ||
|
|
||
| namespace Microsoft.Build.UnitTests.Evaluation | ||
| { | ||
| /// <summary> | ||
| /// Tests for the opt-in partial (stop-after-pass) evaluation model exposed via | ||
| /// <see cref="ProjectOptions.EvaluationStage"/>. | ||
| /// </summary> | ||
| public class PartialEvaluation_Tests : IDisposable | ||
| { | ||
| private readonly ITestOutputHelper _output; | ||
| private readonly TestEnvironment _env; | ||
| private readonly ProjectCollection _collection = new ProjectCollection(); | ||
|
|
||
| public PartialEvaluation_Tests(ITestOutputHelper output) | ||
| { | ||
| _output = output; | ||
| _env = TestEnvironment.Create(_output); | ||
| } | ||
|
|
||
| public void Dispose() | ||
| { | ||
| _collection.Dispose(); | ||
| _env.Dispose(); | ||
| } | ||
|
|
||
| private const string ProjectXml = """ | ||
| <Project> | ||
| <PropertyGroup> | ||
| <Config>Debug</Config> | ||
| <Derived>$(Config)-x</Derived> | ||
| <FromItem>@(Compile)</FromItem> | ||
| </PropertyGroup> | ||
| <ItemDefinitionGroup> | ||
| <Compile> | ||
| <Kind>source</Kind> | ||
| </Compile> | ||
| </ItemDefinitionGroup> | ||
| <ItemGroup> | ||
| <Compile Include="a.cs" /> | ||
| <Compile Include="b.cs" /> | ||
| </ItemGroup> | ||
| <UsingTask TaskName="Dummy" AssemblyName="Some.Assembly" /> | ||
| <Target Name="Build" /> | ||
| <Target Name="Other" /> | ||
| </Project> | ||
| """; | ||
|
|
||
| private static ProjectRootElement CreateRootElement() | ||
| { | ||
| using XmlReader reader = XmlReader.Create(new StringReader(ProjectXml)); | ||
| return ProjectRootElement.Create(reader); | ||
| } | ||
|
|
||
| private ProjectOptions OptionsFor(ProjectEvaluationStage stage) => new ProjectOptions | ||
| { | ||
| EvaluationStage = stage, | ||
| ProjectCollection = _collection, | ||
| }; | ||
|
|
||
| [Fact] | ||
| public void DefaultProjectOptionsStageIsFull() | ||
| { | ||
| new ProjectOptions().EvaluationStage.ShouldBe(ProjectEvaluationStage.Full); | ||
| } | ||
|
|
||
| [Theory] | ||
| [InlineData(0)] | ||
| [InlineData(5)] | ||
| [InlineData(-1)] | ||
| [InlineData(int.MaxValue - 1)] | ||
| public void EvaluationStage_RejectsUndefinedValues(int value) | ||
| { | ||
| Should.Throw<ArgumentOutOfRangeException>(() => new ProjectOptions { EvaluationStage = (ProjectEvaluationStage)value }); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void PropertiesStage_ExposesPropertiesButNotItemsOrTargets_ProjectInstance() | ||
| { | ||
| ProjectInstance instance = ProjectInstance.FromProjectRootElement(CreateRootElement(), OptionsFor(ProjectEvaluationStage.Properties)); | ||
|
|
||
| instance.EvaluationStage.ShouldBe(ProjectEvaluationStage.Properties); | ||
| instance.GetPropertyValue("Derived").ShouldBe("Debug-x"); | ||
|
|
||
| // InitialTargets are computed during pass 1 and remain available. | ||
| Should.NotThrow(() => instance.InitialTargets); | ||
|
|
||
| Should.Throw<InvalidOperationException>(() => instance.Items); | ||
| Should.Throw<InvalidOperationException>(() => instance.ItemDefinitions); | ||
| Should.Throw<InvalidOperationException>(() => instance.GetItems("Compile")); | ||
| Should.Throw<InvalidOperationException>(() => instance.Targets); | ||
| Should.Throw<InvalidOperationException>(() => instance.DefaultTargets); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void PropertiesStage_ExposesPropertiesButNotItemsOrTargets_Project() | ||
| { | ||
| Project project = Project.FromProjectRootElement(CreateRootElement(), OptionsFor(ProjectEvaluationStage.Properties)); | ||
|
|
||
| project.EvaluationStage.ShouldBe(ProjectEvaluationStage.Properties); | ||
| project.GetPropertyValue("Derived").ShouldBe("Debug-x"); | ||
|
|
||
| Should.Throw<InvalidOperationException>(() => project.Items); | ||
| Should.Throw<InvalidOperationException>(() => project.ItemDefinitions); | ||
| Should.Throw<InvalidOperationException>(() => project.GetItems("Compile")); | ||
| Should.Throw<InvalidOperationException>(() => project.AllEvaluatedItems); | ||
| Should.Throw<InvalidOperationException>(() => project.Targets); | ||
| } | ||
|
ViktorHofer marked this conversation as resolved.
|
||
|
|
||
| [Fact] | ||
| public void PropertyValuesEqualFullEvaluation() | ||
| { | ||
| ProjectInstance partial = ProjectInstance.FromProjectRootElement(CreateRootElement(), OptionsFor(ProjectEvaluationStage.Properties)); | ||
| ProjectInstance full = ProjectInstance.FromProjectRootElement(CreateRootElement(), OptionsFor(ProjectEvaluationStage.Full)); | ||
|
|
||
| partial.GetPropertyValue("Derived").ShouldBe(full.GetPropertyValue("Derived")); | ||
|
|
||
| // Item references in property values expand to empty even in a full evaluation because | ||
| // properties are evaluated before items, so both stages agree. | ||
| partial.GetPropertyValue("FromItem").ShouldBe(full.GetPropertyValue("FromItem")); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ItemDefinitionsStage_ExposesItemDefinitionsButNotItemsOrTargets() | ||
| { | ||
| ProjectInstance instance = ProjectInstance.FromProjectRootElement(CreateRootElement(), OptionsFor(ProjectEvaluationStage.ItemDefinitions)); | ||
|
|
||
| instance.EvaluationStage.ShouldBe(ProjectEvaluationStage.ItemDefinitions); | ||
| Should.NotThrow(() => instance.ItemDefinitions); | ||
|
|
||
| Should.Throw<InvalidOperationException>(() => instance.Items); | ||
| Should.Throw<InvalidOperationException>(() => instance.Targets); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ItemsStage_ExposesItemsButNotTargets() | ||
| { | ||
| ProjectInstance instance = ProjectInstance.FromProjectRootElement(CreateRootElement(), OptionsFor(ProjectEvaluationStage.Items)); | ||
|
|
||
| instance.EvaluationStage.ShouldBe(ProjectEvaluationStage.Items); | ||
| instance.GetItems("Compile").Count.ShouldBe(2); | ||
|
|
||
| Should.Throw<InvalidOperationException>(() => instance.Targets); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void FullStage_IsDefault_AndExposesEverything() | ||
| { | ||
| ProjectInstance instance = ProjectInstance.FromProjectRootElement(CreateRootElement(), new ProjectOptions { ProjectCollection = _collection }); | ||
|
|
||
| instance.EvaluationStage.ShouldBe(ProjectEvaluationStage.Full); | ||
| instance.GetItems("Compile").Count.ShouldBe(2); | ||
| instance.Targets.ShouldContainKey("Build"); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ReevaluateUpgradesPartialProjectToFull() | ||
| { | ||
| Project project = Project.FromProjectRootElement(CreateRootElement(), OptionsFor(ProjectEvaluationStage.Properties)); | ||
|
|
||
| project.EvaluationStage.ShouldBe(ProjectEvaluationStage.Properties); | ||
| Should.Throw<InvalidOperationException>(() => project.Targets); | ||
|
|
||
| project.ReevaluateIfNecessary(); | ||
|
|
||
| project.EvaluationStage.ShouldBe(ProjectEvaluationStage.Full); | ||
| project.Targets.ShouldContainKey("Build"); | ||
| project.GetItems("Compile").Count.ShouldBe(2); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void CreateProjectInstanceFromPartialProjectUpgradesToFull() | ||
| { | ||
| Project project = Project.FromProjectRootElement(CreateRootElement(), OptionsFor(ProjectEvaluationStage.Properties)); | ||
| project.EvaluationStage.ShouldBe(ProjectEvaluationStage.Properties); | ||
|
|
||
| ProjectInstance instance = project.CreateProjectInstance(); | ||
|
|
||
| instance.EvaluationStage.ShouldBe(ProjectEvaluationStage.Full); | ||
| instance.Targets.ShouldContainKey("Build"); | ||
| project.EvaluationStage.ShouldBe(ProjectEvaluationStage.Full); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void PartialProjectInstanceCannotBeBuilt() | ||
| { | ||
| ProjectInstance instance = ProjectInstance.FromProjectRootElement(CreateRootElement(), OptionsFor(ProjectEvaluationStage.Properties)); | ||
|
|
||
| Should.Throw<InvalidOperationException>(() => new BuildRequestData(instance, new[] { "Build" })); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void CacheDoesNotServePartialProjectForFullLoad() | ||
| { | ||
| TransientTestFile file = _env.CreateFile("test.proj", ProjectXml); | ||
|
|
||
| Project partial = Project.FromFile(file.Path, new ProjectOptions | ||
| { | ||
| EvaluationStage = ProjectEvaluationStage.Properties, | ||
| ProjectCollection = _collection, | ||
| }); | ||
| partial.EvaluationStage.ShouldBe(ProjectEvaluationStage.Properties); | ||
|
|
||
| // A subsequent full LoadProject on the same key must return a fully-evaluated project | ||
| // (the cached partial one is upgraded in place), never partial state. | ||
| Project full = _collection.LoadProject(file.Path); | ||
| full.EvaluationStage.ShouldBe(ProjectEvaluationStage.Full); | ||
| full.Targets.ShouldContainKey("Build"); | ||
|
|
||
| // The partial and full references point at the same upgraded cached project. | ||
| ReferenceEquals(partial, full).ShouldBeTrue(); | ||
| partial.EvaluationStage.ShouldBe(ProjectEvaluationStage.Full); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.