Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions documentation/specs/proposed/partial-evaluation.md
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 src/Build.UnitTests/Evaluation/PartialEvaluation_Tests.cs
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);
}
Comment thread
ViktorHofer marked this conversation as resolved.

[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);
}
Comment thread
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);
}
}
}
5 changes: 5 additions & 0 deletions src/Build/BackEnd/BuildManager/BuildRequestData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ public BuildRequestData(ProjectInstance projectInstance, string[] targetsToBuild
{
ArgumentNullException.ThrowIfNull(projectInstance);

if (projectInstance.EvaluationStage != Evaluation.ProjectEvaluationStage.Full)
{
Shared.ErrorUtilities.ThrowInvalidOperation("OM_PartialEvaluationCannotBuild", projectInstance.EvaluationStage);
}

foreach (string targetName in targetsToBuild)
{
ArgumentNullException.ThrowIfNull(targetName, "target");
Expand Down
Loading
Loading