Skip to content
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

Parallelize tests #17872

Draft
wants to merge 38 commits into
base: main
Choose a base branch
from
Draft

Parallelize tests #17872

wants to merge 38 commits into from

Conversation

majocha
Copy link
Contributor

@majocha majocha commented Oct 11, 2024

Enable running xUnit tests in parallel.

To use xUnit means to customize it. Two optional features added:

  • Running collection and theory cases in parallel based on https://www.meziantou.net/parallelize-test-cases-execution-in-xunit.htm
    By default xUnit's unit of parallelization is test collection. Test cases in a collection run in sequence. Also, by default each class/module constitutes a collection. We have a lot of test cases in large modules and large theories that were bottlenecked by this.
    This customization enables parallelism in such cases. It can be reverted to default for a particular module with [<RunInSequence>] attribute.
    Relevant issue is on the roadmap for xUnit v3, so this will probably become unnecessary in the future.

  • Console streams captured universally and redirected to xUnit's output mechanism, which means you can just do printfn in a test case and it goes to the respective output.
    image
    This can be inspected in the IDE and in case of failure is printed out when testing from the command line.
    image

The default way in xUnit is to use ITestOutputHelper. This is very unwieldy, because it requires placing test cases in a class with a constructor, and then threading the injected output helper into any function that wants to output text. We have many tests in modules not classes, and many of the tests are using a lot of utility functions. Adjusting it all to use ITestOutputHelper is not feasible. OTOH just outputting with printfn is unobtrusive, natural and works well with interactive prototyping of test cases.
This customization will probably become unnecessary with xUnit v3.

The above customizations are not required for the test suite to work correctly. They are contained to the XunitHelpers.fs file and enabled with conditional compilation using XUNIT_EXTRAS constant defined in FSharp.Test.Utilities.fsproj

Some local run times:

dotnet test .\tests\FSharp.Compiler.ComponentTests\ -c Release -f net9.0

Test summary: total: 4489, failed: 0, succeeded: 4258, skipped: 231, duration: 199.0s

dotnet test .\tests\fsharp\ -c Release -f net9.0

Test summary: total: 579, failed: 0, succeeded: 579, skipped: 0, duration: 41.9s

dotnet test .\FSharp.sln -c Release  -f net9.0

Test summary: total: 12963, failed: 0, succeeded: 12694, skipped: 269, duration: 253.3s

Some considerations to make this work and keep it working
To run tests in parallel we must deal with global resources and global state accessed by the test cases.

Out of proc:
Tests running as separate processes are sharing the file system. We must make sure they execute in their own temporary directories and don't overwrite any hardcoded paths. This is already done, mostly in separate PR.

Hosted:
Many tests use hosted compiler and FsiEvaluationSession, sharing global resources and global state within the runner process:

  • Console streams - this is swept under a rug for now by using a simple AsyncLocal stream splitter.
  • FileSystem global mutable of the file system shim - few tests that mutate it, must be excluded from parallelization.
  • Environment.CurrentDirectory - many tests executing in hosted session were doing a variation of File.WriteAllText("test.ok", "ok") all in the current directory i.e. bin, leading to conflicts. This is replaced with a threadsafe mechanism.
  • Environment variables, Path - mostly this applies to DependencyManager, excluded from parallelization for now.
  • Async default cancellation token - few tests doing Async.CancelDefaultToken() must be excluded from parallelization.
  • global state used in conjunction with --times option - tests excluded from parallelization.
  • global mutable state in the form of multiple caches implemented as ConcurrentDictionary. This seems no longer a problem, contained using some exclusions from parallelization.

I'll ad to the above list if I recall anything else.

Problems:
Tests depending on tight timing, orchestrating stuff by combinations of Thread.Sleep, Async.Sleep and wait timeouts.
These are mostly excluded from parallelization, some attempts at fixing things were made.

Obscure compiler bugs revealed in this PR:

  • Internal error: value cannot be null this mostly happens in coreClr, one time, sometimes a few times during the test run.

  • Error creating evaluation session because of NRE somewhere in TcImports.BuildNonFrameworkTcImports. This is more rare but may be related to the above.

These were related to some concurrency issues; modyfing frameworkTcImportsCache without lock and a bug in custom lazy implementation in il.fs. Hopefully both fixed now.

Running in parallel:
Xunit runners are configured with mostly default parallelization settings.

dotnet test .\FSharp.sln -c Release -f net9.0 will run all discovered test assemblies in parallel as soon as they're built.
This can be limited with the -m switch. For example,
dotnet test -m:2 .\FSharp.Compiler.Service.sln
will limit the test run to at most 2 simultaneous processes. Still, each test host process runs its test collections in parallel.

Some test collections are excluded form parallelization with [<Collection(nameof DoNotRunInParallel)>] attribute.

Running in the IDE with "Run tests in parallel" enabled will respect xunit.runner.json settings and the above exclusions.

TODO:

@majocha majocha mentioned this pull request Oct 11, 2024
8 tasks
Copy link
Contributor

github-actions bot commented Oct 11, 2024

⚠️ Release notes required, but author opted out

Warning

Author opted out of release notes, check is disabled for this pull request.
cc @dotnet/fsharp-team-msft

@majocha majocha changed the title Parallelize tests, continuation Parallelize tests Oct 12, 2024
@majocha majocha force-pushed the parallel-tests branch 6 times, most recently from decc8cb to 278e2ec Compare October 13, 2024 09:01
@psfinaki
Copy link
Member

Thanks for your endurance, Jakub 💪

@majocha
Copy link
Contributor Author

majocha commented Oct 18, 2024

@psfinaki I will need some help with this Source-Build error:
https://dev.azure.com/dnceng-public/public/_build/results?buildId=847523&view=logs&j=2f0d093c-1064-5c86-fc5b-b7b1eca8e66a&t=52d0a7a6-39c9-5fa2-86e8-78f84e98a3a2&l=45

At this moment this is very stable locally but will also probably need testing on other machines than mine :)

What's left to do is to tune this for stability in CI. I've been trying different things and timing runs. The most glaring problem is the testDesktop. In CI desktop runs both FSharpSuite and ComponentTests take around 40 minutes each. I guess slicing the test suite and running with multi-agent parallel strategy would improve things here.
I added some simple provisions for easier slicing using traits: --filter ExecutionNode=n will now take a stable slice of the test suite (currently n is hardcoded 1..4)

I also noticed Linux run is constantly low on memory, this is unrelated as it happens on main, too. For this reason I set MaxParallelThreads=4 in build.sh to cool things down a bit.

@psfinaki
Copy link
Member

@majocha the error is weird, nothing comes to my mind right away. Let's rebase and rerun and see if it's still happening... Sorry, I know this is somewhat lame, it's just that SourceBuild is a Linux thing and it's not trivial to debug its issues locally.

As for cooling things down - I also noticed this today, thanks for addressing this.

What else do you think we can split from this PR into some separate ones?

@majocha
Copy link
Contributor Author

majocha commented Oct 21, 2024

What else do you think we can split from this PR into some separate ones?

There are some small further fixes, maybe also the whole console handling does not really depend on parallel execution.

Somewhat related thing I have on my mind recently is to implement a FileSystem shim for tests that will be as much in-memory as possible and isolated per testcase. It wouldn't handle the tests that start separate processes, though.

@majocha
Copy link
Contributor Author

majocha commented Oct 21, 2024

@majocha the error is weird, nothing comes to my mind right away. Let's rebase and rerun and see if it's still happening... Sorry, I know this is somewhat lame, it's just that SourceBuild is a Linux thing and it's not trivial to debug its issues locally.

Thanks! Rebasing did help.

@psfinaki psfinaki added the NO_RELEASE_NOTES Label for pull requests which signals, that user opted-out of providing release notes label Oct 21, 2024
@psfinaki
Copy link
Member

There are some small further fixes, maybe also the whole console handling does not really depend on parallel execution.

Yeah console handling would be probably good to isolate if possible.

Somewhat related thing I have on my mind recently is to implement a FileSystem shim for tests that will be as much in-memory as possible and isolated per testcase. It wouldn't handle the tests that start separate processes, though.

Just for my understanding, what would this add on top of the current results the PR achieves?

@majocha
Copy link
Contributor Author

majocha commented Oct 21, 2024

Just for my understanding, what would this add on top of the current results the PR achieves?

This would be an experiment for another PR, but basically, I don't like all that copying to temp dirs that I added in recent PRs.
A FileSystem shim just for testing, that virtualizes all of the writes and keeps track of which test case wrote what, to correctly isolate them, would be maybe possibly more performant and a cleaner solution.

@psfinaki
Copy link
Member

Right, yeah, I see. No it's worth playing with, although given that we don't touch these tests too much, it's probably worth seriously investing into only if it starts yielding reasonable performance fruits.

@@ -252,6 +252,7 @@ let processGraphAsync<'Item, 'Result when 'Item: equality and 'Item: comparison>
let rec queueNode node =
Async.Start(
async {
use! _catch = Async.OnCancel(completionSignal.TrySetCanceled >> ignore)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fix. This was starting with a cancellation token but without catching the OCE.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is more complicated and Async.OnCancel of course does not catch OCE. What was I thinking?

src/FSharp.Core/mailbox.fs Outdated Show resolved Hide resolved
// Build command line arguments & start FSI session
let argv = [| "C:\\fsi.exe" |]
let allArgs = Array.append argv [|"--noninteractive"; if useOneDynamicAssembly then "--multiemit-" else "--multiemit+" |]

let fsiConfig = FsiEvaluationSession.GetDefaultConfiguration()
FsiEvaluationSession.Create(fsiConfig, allArgs, inStream, new StreamWriter(outStream), new StreamWriter(errStream), collectible = true)
FsiEvaluationSession.Create(fsiConfig, allArgs, TextReader.Null, TextWriter.Null, TextWriter.Null, collectible = true)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

There are actually no tests using these streams.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But they are useful for capturing test errors. This should be stdout / stderr.

@majocha
Copy link
Contributor Author

majocha commented Nov 7, 2024

https://dev.azure.com/dnceng-public/public/_build/results?buildId=863457&view=logs&j=7bab896a-24f8-544f-51eb-43745367a332&t=999dbed9-85e3-59ab-57f0-3e22828b5bad&l=3823 I've seen this test hang also locally, but it takes like few hundreds of iterations to trigger the deadlock (?).

@majocha
Copy link
Contributor Author

majocha commented Nov 7, 2024

TODO, two test cases have poorer stability now:
error on one workflow should cancel all others throws
Test project1 and make sure TcImports gets cleaned up timeout (blame-hang-timeout turned out to be helpful)
Needs fix or revert to original.

Copy link
Member

@psfinaki psfinaki left a comment

Choose a reason for hiding this comment

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

Took another look through the changes. Good stuff, thanks for your enduring efforts here :)

@@ -137,7 +137,7 @@ module internal PervasiveAutoOpens =
type Async with

static member RunImmediate(computation: Async<'T>, ?cancellationToken) =
let cancellationToken = defaultArg cancellationToken Async.DefaultCancellationToken
let cancellationToken = defaultArg cancellationToken CancellationToken.None
Copy link
Member

Choose a reason for hiding this comment

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

Maybe it's worth having cancellation token changes separately as well, for a more attentive review.

@@ -8,6 +8,8 @@ open FSharp.Test.Compiler
open System
open System.IO

// reportTime uses global state.
Copy link
Member

Choose a reason for hiding this comment

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

I like these explanations, good stuff, thanks.

@@ -14,5 +14,5 @@ let success =
asm.Version.Major = 1 &&
asm.Version.Minor = 2 &&
asm.Version.Build = 3 &&
(abs (asm.Version.Revision - (int defaultRevision))) < 10 // default value is seconds in the current day / 2. Check if within 10 sec of that.
(abs (asm.Version.Revision - (int defaultRevision))) < 60 // default value is seconds in the current day / 2. Check if within 60 sec of that.
Copy link
Member

Choose a reason for hiding this comment

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

This is flaky otherwise, right? Wonder if we can come up with a timeout-independent fix.

@@ -93,14 +93,17 @@ let ``comprehensions-FSC_OPTIMIZED`` () = singleTestBuildAndRun "core/comprehens
[<Fact>]
let ``comprehensions-FSI`` () = singleTestBuildAndRun "core/comprehensions" FSI

[<Fact>]
let ``comprehensionshw-FSC_DEBUG`` () = singleTestBuildAndRun "core/comprehensions-hw" FSC_DEBUG
// Cancels default token.
Copy link
Member

Choose a reason for hiding this comment

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

For my understanding, do we have a reasonable explanation on why it's happening? (default token cancellation)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, just some test cases call it directly.

@@ -479,6 +458,9 @@ let x =
script.Eval(code) |> ignoreValue
Assert.False(foundInner)

// Fails in NETFRAMEWORK with exception
Copy link
Member

Choose a reason for hiding this comment

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

Does it mean it wasn't failing before?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, no, iirc this whole test project was excluded on net472 and I re-enabled it for some reason.

Copy link
Contributor Author

@majocha majocha Nov 12, 2024

Choose a reason for hiding this comment

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

Nope I rememebered wrong, I need to revisit this.

let getTempPath dir =
Path.Combine(TestFramework.tempDirectoryOfThisTestRun, dir)
let getTempPath dir =
Path.Combine(tempDirectoryOfThisTestRun.Value.FullName, dir)
Copy link
Member

Choose a reason for hiding this comment

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

Not a fan of forcing options, but not such a big deal in tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, this is a lazy, not an option. I tried to solve the temp dir cleanup somehow in a centralized manner and this is part of the solution.

@@ -14,21 +14,15 @@ type Sentinel () =
module MyModule =
let test(x: int) = ()

[<CollectionDefinition("FsiTests", DisableParallelization = true)>]
Copy link
Member

Choose a reason for hiding this comment

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

Do I get it right that these are working even without applying the new xUnit matters?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, collection definition is pure xUnit.

@majocha
Copy link
Contributor Author

majocha commented Nov 12, 2024

😞 this is new.
https://dev.azure.com/dnceng-public/public/_build/results?buildId=867529&view=logs&j=e2e0a842-382a-5483-7008-fc809f5eff8d&t=6a49776c-db36-5de0-b127-a6a0074bf26a&l=14859
Fatal error. Internal CLR error. (0x80131506)

https://dev.azure.com/dnceng-public/public/_build/results?buildId=867536&view=logs&j=2363a781-3528-55f4-1450-adabad4eb250&t=4ee64dfd-475b-5640-3e58-0c1d0f6688da&l=20
D:\a\_work\1\s\tests\EndToEndBuildTests\BasicProvider\BasicProvider\BasicProvider.fsproj : error MSB4057: The target "_GetRestoreSettingsPerFramework" does not exist in the project. [TargetFramework=net9.0]
This also started today.

@psfinaki
Copy link
Member

/azp run

Copy link

Azure Pipelines successfully started running 2 pipeline(s).

@majocha
Copy link
Contributor Author

majocha commented Nov 12, 2024

Most of the Utilities refactoring and the parallel console support is extracted to #17993. This excludes the xUnit customizations and parallelization of the tests. I hope to make sure this still works and tests fine with the state of the things we have in main.

@psfinaki
Copy link
Member

Awesome, thanks for splitting that - we will take a look there shortly :)

@majocha
Copy link
Contributor Author

majocha commented Nov 12, 2024

I don't know what's up with the _GetRestoreSettingsPerFramework error, though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
NO_RELEASE_NOTES Label for pull requests which signals, that user opted-out of providing release notes
Projects
Status: New
Development

Successfully merging this pull request may close these issues.

2 participants