Skip to content

Conversation

jjonescz
Copy link
Member

@jjonescz jjonescz commented Sep 4, 2025

Part of #48011.

This optimization saves CSC arguments when MSBuild runs and then reuses them in subsequent runs so dotnet run file.cs can skip MSBuild and invoke only the compiler.

In my simple experiments, previously the re-runs (using MSBuild) would take ~4 seconds, now (using CSC) they take ~1 second.

@jjonescz jjonescz added the Area-run-file Items related to the "dotnet run <file>" effort label Sep 4, 2025
@jjonescz jjonescz marked this pull request as ready for review September 4, 2025 13:56
@jjonescz jjonescz requested review from a team and Copilot September 4, 2025 13:56
Copy link
Contributor

@Copilot 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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@RikkiGibson RikkiGibson self-assigned this Sep 5, 2025
Copy link
Member

@RikkiGibson RikkiGibson left a comment

Choose a reason for hiding this comment

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

Looks good but had some comments to follow up on.

internal static bool ArgumentContainsWhitespace(string argument) =>
argument.Contains(" ") || argument.Contains("\t") || argument.Contains("\n");

// Taken from Roslyn's CommandLineParser.
Copy link
Member

Choose a reason for hiding this comment

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

nit: it would be good to provide a permalink so that if things change over time we can compare.

// Finds /out: argument and extract the file path from it.
string? FindOutputFile()
{
const string outPrefix = "/out:";
Copy link
Member

Choose a reason for hiding this comment

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

Just curious, did you consider using CSharpCommandLineParser.Parse for this? Probably that allocates quite a bit more.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hm, you're right it's doing unnecessary work but I won't need to depend on internal compiler command line parsing details by using it, hence I like it!

{
rspPath = Path.Join(ArtifactsPath, "csc.rsp");

if (!CscArguments.IsDefaultOrEmpty)
Copy link
Member

Choose a reason for hiding this comment

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

any risk of an unexpected behavior here if this changes from empty to non-empty across different runs? e.g. from a stale .rsp file being present in the directory.

Copy link
Member Author

@jjonescz jjonescz Sep 9, 2025

Choose a reason for hiding this comment

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

Good catch, I think there is indeed a problem when the "reusing CSC args" vs "built-in CSC args" optimizations are used after each other. I will add a test and fix that.

Other than that, I think there should be no problems. The "reusing CSC args" optimization always regenerates the rsp file. The "build-in CSC args" optimization should regenerate the rsp file whenever it's out of date.

// See https://github.com/dotnet/msbuild/blob/main/documentation/specs/build-nonexistent-projects-by-default.md.
{ "_BuildNonexistentProjectsByDefault", bool.TrueString },
{ "RestoreUseSkipNonexistentTargets", bool.FalseString },
{ "ProvideCommandLineArgs", bool.TrueString },
Copy link
Member

Choose a reason for hiding this comment

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

nit: looks like capacity is out of date now, should the capacity just be deleted?

return null;
}

void GetCscArguments(CacheInfo cache, BuildResult result, ProjectInstance projectInstance)
Copy link
Member

Choose a reason for hiding this comment

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

It feels like a method should not be named Get... if it returns void. Maybe CacheCscArguments or TryCacheCscArguments.


public bool DetermineFinalCanReuseAuxiliaryFiles()
{
if (PreviousEntry?.CscArguments.IsDefaultOrEmpty == false)
Copy link
Member

Choose a reason for hiding this comment

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

Kind of an aside, but, is it valid to say that we can reuse something from the previous run, if we don't even have a cache entry from the previous run? Possibly I am misunderstanding something?

Basically I am wondering if things can be simplified slightly by just saying if (PreviousEntry is null) return false;.

Copy link
Member Author

@jjonescz jjonescz Sep 9, 2025

Choose a reason for hiding this comment

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

If PreviousEntry is null, we will actually return false on line 512 but also write a verbose message saying why ("CSC auxiliary files can NOT be reused because previous build level was not CSC (it was N/A)."). In case we have CSC arguments from previous run (PreviousEntry?.CscArguments.IsDefaultOrEmpty == false), I intentionally didn't want to write any message because that would be superfluous ("csc auxiliary file reuse" is irrelevant in that code path).

Reporter.Verbose.WriteLine("We have CSC arguments but not build result file. That's unexpected.");
}
else if (cache.PreviousEntry.Directives.Length != cache.CurrentEntry.Directives.Length ||
!cache.PreviousEntry.Directives.SequenceEqual(cache.CurrentEntry.Directives))
Copy link
Member

Choose a reason for hiding this comment

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

nit: SequenceEqual already returns false when lengths are not equal. No need to test it in advance.

Copy link
Member Author

Choose a reason for hiding this comment

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

IEnumerable one wouldn't do that, but you're right this one does since it's on ImmutableArray args, thanks

// Don't reuse CSC arguments, this is the "simple" CSC-only build.
if (cache.PreviousEntry?.CscArguments.IsDefaultOrEmpty == false)
{
cache.PreviousEntry.CscArguments = [];
Copy link
Member

Choose a reason for hiding this comment

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

It feels reasonable to me to always assign these in this code path. I am unsure if, for example, we want to avoid doing these assignments when CscArguments.IsDefault.

Copy link
Member Author

Choose a reason for hiding this comment

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

You're right, I'm not sure why I put the condition there.

}

/// <summary>
/// Up-to-date checks and optimizations currently don't support other included files.
Copy link
Member

Choose a reason for hiding this comment

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

Should we block the optimizations, implicitly/by default, when such files are present? Or is there follow-up coming to address this?

Copy link
Member Author

@jjonescz jjonescz Sep 9, 2025

Choose a reason for hiding this comment

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

Well, detecting that such files are present might be slow, hence checking for them would slow down the optimized paths. (We are also fine with the dotnet run file.cs optimizations being only "best effort" in general, although included files might be too important to ignore.) Anyway, I'm planning a follow up to investigate the options here (it is kind of unrelated to this PR though, the pre-existing optimizations already ignore included files).

}

private void Build(TestDirectory testInstance, BuildLevel level, ReadOnlySpan<string> args = default, string expectedOutput = "Hello from Program")
private void Build(TestDirectory testInstance, BuildLevel level, ReadOnlySpan<string> args = default, string expectedOutput = "Hello from Program", string programFileName = "Program.cs")
Copy link
Member

Choose a reason for hiding this comment

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

I feel parameter level should be renamed to expectedLevel, to make it more clear this parameter is not instructing the build process to behave a certain way, we are verifying that it made the choice we expected it to make.

It might be helpful for clarity of the tests, to split this between Build and Verify helpers like many of the compiler tests do. e.g. Build(testInstance, args, programFileName).Verify(expectedLevel, expectedOutput). You may wish to consider making such a change at your discretion.

Copy link
Member Author

@jjonescz jjonescz Sep 9, 2025

Choose a reason for hiding this comment

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

Hm, a Build/Verify split would improve readability but I also often see in the roslyn codebase people unintentionally forgetting to call VerifyDiagnostics after calling CreateCompilation, hence I'm unsure if it's worth it.

@jjonescz jjonescz requested a review from 333fred September 9, 2025 19:21
@jjonescz
Copy link
Member Author

@333fred for a review, thanks

@jjonescz jjonescz merged commit d9cf033 into dotnet:release/10.0.1xx Sep 11, 2025
27 checks passed
@jjonescz jjonescz deleted the sprint-perf-3 branch September 11, 2025 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area-run-file Items related to the "dotnet run <file>" effort

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants