Skip to content

Commit eb7cf3c

Browse files
authored
perf: remove EnumerableAsyncProcessor dependency (#6465)
* perf: remove EnumerableAsyncProcessor dependency Replace the EnumerableAsyncProcessor package with an internal ParallelMap helper: a bounded-parallelism async map where a fixed set of workers pulls items via an interlocked cursor. Results preserve source order with no partitioner, channel, or per-item task allocations, and sets below 8 items run sequentially to skip task scheduling overhead entirely. - Consolidate the duplicated sequential/parallel/streaming build paths in TestBuilderPipeline onto one shared per-metadata method - Delete the fake-streaming IAsyncEnumerable path (it fully buffered before yielding) and its dead generic-resolution error helper - Flatten build results eagerly with exact capacity instead of a lazy SelectMany that downstream code enumerated twice - Rewrite the NuGet upload module loop without the library * Address review: converge on ParallelMap, add tests, fix naming - Route InvokePostResolutionEventsInParallelAsync through a new ParallelMap.ForEachParallelAsync (ValueTask action, no results array), deleting the #if NET8_0_OR_GREATER Parallel.ForEachAsync split whose netstandard2.0 fallback was unbounded - Add dedicated ParallelMapTests: order preservation, threshold boundaries, exactly-once invocation, DOP cap, exception propagation and fail-fast cursor parking, cancellation - Rename fake-streaming BuildTestsStreamingAsync to BuildTestsAsync and delete the dead zero-caller BuildTestsAsync(sessionId) overload
1 parent ede5377 commit eb7cf3c

7 files changed

Lines changed: 435 additions & 339 deletions

File tree

Directory.Packages.props

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
<PackageVersion Include="BenchmarkDotNet.Annotations" Version="0.15.8" />
1313
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
1414
<PackageVersion Include="CliWrap" Version="3.10.2" />
15-
<PackageVersion Include="EnumerableAsyncProcessor" Version="3.8.4" />
1615
<PackageVersion Include="FakeItEasy" Version="9.0.1" />
1716
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
1817
<PackageVersion Include="FsCheck" Version="3.3.3" />

src/TUnit.Engine/Building/TestBuilderPipeline.cs

Lines changed: 49 additions & 292 deletions
Large diffs are not rendered by default.

src/TUnit.Engine/TUnit.Engine.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
</ItemGroup>
1414

1515
<ItemGroup>
16-
<PackageReference Include="EnumerableAsyncProcessor" />
1716
<PackageReference Include="Microsoft.Testing.Extensions.TrxReport.Abstractions" />
1817
<PackageReference Include="Microsoft.Testing.Platform" />
1918
</ItemGroup>

src/TUnit.Engine/TestDiscoveryService.cs

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ private async IAsyncEnumerable<AbstractExecutableTest> DiscoverTestsStreamAsync(
233233
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
234234
cts.CancelAfter(EngineDefaults.DiscoveryTimeout);
235235

236-
var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, buildingContext, metadataFilter: null, cts.Token).ConfigureAwait(false);
236+
var tests = await _testBuilderPipeline.BuildTestsAsync(testSessionId, buildingContext, metadataFilter: null, cts.Token).ConfigureAwait(false);
237237

238238
foreach (var test in tests)
239239
{
@@ -382,34 +382,12 @@ public async IAsyncEnumerable<AbstractExecutableTest> DiscoverTestsFullyStreamin
382382

383383

384384

385-
private async Task InvokePostResolutionEventsInParallelAsync(List<AbstractExecutableTest> allTests)
385+
private Task InvokePostResolutionEventsInParallelAsync(List<AbstractExecutableTest> allTests)
386386
{
387-
if (allTests.Count < Building.ParallelThresholds.MinItemsForParallel)
388-
{
389-
foreach (var test in allTests)
390-
{
391-
await _testBuilderPipeline.InvokePostResolutionEventsAsync(test).ConfigureAwait(false);
392-
}
393-
return;
394-
}
395-
396-
#if NET8_0_OR_GREATER
397-
await Parallel.ForEachAsync(
387+
return Utilities.ParallelMap.ForEachParallelAsync(
398388
allTests,
399-
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
400-
async (test, _) =>
401-
{
402-
await _testBuilderPipeline.InvokePostResolutionEventsAsync(test).ConfigureAwait(false);
403-
}
404-
).ConfigureAwait(false);
405-
#else
406-
var tasks = new Task[allTests.Count];
407-
for (var i = 0; i < allTests.Count; i++)
408-
{
409-
tasks[i] = _testBuilderPipeline.InvokePostResolutionEventsAsync(allTests[i]).AsTask();
410-
}
411-
await Task.WhenAll(tasks).ConfigureAwait(false);
412-
#endif
389+
test => _testBuilderPipeline.InvokePostResolutionEventsAsync(test),
390+
Environment.ProcessorCount);
413391
}
414392

415393
public IEnumerable<TestContext> GetCachedTestContexts()
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
namespace TUnit.Engine.Utilities;
2+
3+
/// <summary>
4+
/// Allocation-light bounded-parallelism async map.
5+
/// Results preserve source order. A fixed set of workers pulls items via an interlocked
6+
/// cursor, so no partitioner, channel, or per-item task is allocated.
7+
/// Sets smaller than <see cref="SequentialThreshold"/> run sequentially, where task
8+
/// scheduling overhead would exceed the parallelization benefit.
9+
/// </summary>
10+
internal static class ParallelMap
11+
{
12+
/// <summary>
13+
/// Minimum number of items before parallel processing is used.
14+
/// </summary>
15+
public const int SequentialThreshold = 8;
16+
17+
/// <summary>
18+
/// Void counterpart of <see cref="SelectParallelAsync{TSource,TResult}"/> — same worker
19+
/// model without the results array. Takes a ValueTask action so synchronously-completing
20+
/// callees don't allocate a Task per item.
21+
/// </summary>
22+
public static async Task ForEachParallelAsync<TSource>(
23+
IReadOnlyList<TSource> source,
24+
Func<TSource, ValueTask> action,
25+
int maxDegreeOfParallelism,
26+
CancellationToken cancellationToken = default)
27+
{
28+
var count = source.Count;
29+
30+
if (count == 0)
31+
{
32+
return;
33+
}
34+
35+
var workerCount = Math.Min(maxDegreeOfParallelism, count);
36+
37+
if (workerCount <= 1 || count < SequentialThreshold)
38+
{
39+
for (var i = 0; i < count; i++)
40+
{
41+
cancellationToken.ThrowIfCancellationRequested();
42+
await action(source[i]).ConfigureAwait(false);
43+
}
44+
45+
return;
46+
}
47+
48+
var cursor = -1;
49+
50+
Func<Task> worker = WorkerAsync;
51+
var workers = new Task[workerCount];
52+
for (var w = 0; w < workerCount; w++)
53+
{
54+
workers[w] = Task.Run(worker, CancellationToken.None);
55+
}
56+
57+
await Task.WhenAll(workers).ConfigureAwait(false);
58+
59+
return;
60+
61+
async Task WorkerAsync()
62+
{
63+
while (true)
64+
{
65+
var index = Interlocked.Increment(ref cursor);
66+
67+
if (index >= count)
68+
{
69+
return;
70+
}
71+
72+
cancellationToken.ThrowIfCancellationRequested();
73+
74+
try
75+
{
76+
await action(source[index]).ConfigureAwait(false);
77+
}
78+
catch
79+
{
80+
// Park the cursor so sibling workers stop pulling new items;
81+
// Task.WhenAll surfaces this fault once in-flight items finish.
82+
Volatile.Write(ref cursor, count);
83+
throw;
84+
}
85+
}
86+
}
87+
}
88+
89+
public static Task<TResult[]> SelectParallelAsync<TSource, TResult>(
90+
IReadOnlyList<TSource> source,
91+
Func<TSource, Task<TResult>> selector,
92+
int maxDegreeOfParallelism,
93+
CancellationToken cancellationToken = default)
94+
=> ForParallelAsync(source.Count, index => selector(source[index]), maxDegreeOfParallelism, cancellationToken);
95+
96+
public static async Task<TResult[]> ForParallelAsync<TResult>(
97+
int count,
98+
Func<int, Task<TResult>> selector,
99+
int maxDegreeOfParallelism,
100+
CancellationToken cancellationToken = default)
101+
{
102+
if (count == 0)
103+
{
104+
return [];
105+
}
106+
107+
var results = new TResult[count];
108+
var workerCount = Math.Min(maxDegreeOfParallelism, count);
109+
110+
if (workerCount <= 1 || count < SequentialThreshold)
111+
{
112+
for (var i = 0; i < count; i++)
113+
{
114+
cancellationToken.ThrowIfCancellationRequested();
115+
results[i] = await selector(i).ConfigureAwait(false);
116+
}
117+
118+
return results;
119+
}
120+
121+
var cursor = -1;
122+
123+
// Task.Run rather than invoking WorkerAsync directly: a selector that completes
124+
// synchronously would otherwise run the entire loop inline on this thread and
125+
// serialize all the other workers.
126+
Func<Task> worker = WorkerAsync;
127+
var workers = new Task[workerCount];
128+
for (var w = 0; w < workerCount; w++)
129+
{
130+
workers[w] = Task.Run(worker, CancellationToken.None);
131+
}
132+
133+
await Task.WhenAll(workers).ConfigureAwait(false);
134+
135+
return results;
136+
137+
async Task WorkerAsync()
138+
{
139+
while (true)
140+
{
141+
var index = Interlocked.Increment(ref cursor);
142+
143+
if (index >= count)
144+
{
145+
return;
146+
}
147+
148+
cancellationToken.ThrowIfCancellationRequested();
149+
150+
try
151+
{
152+
results[index] = await selector(index).ConfigureAwait(false);
153+
}
154+
catch
155+
{
156+
// Park the cursor so sibling workers stop pulling new items;
157+
// Task.WhenAll surfaces this fault once in-flight items finish.
158+
Volatile.Write(ref cursor, count);
159+
throw;
160+
}
161+
}
162+
}
163+
}
164+
}

0 commit comments

Comments
 (0)