diff --git a/src/TUnit.Engine/Reporters/Aggregation/ParallelJsonArrayWriter.cs b/src/TUnit.Engine/Reporters/Aggregation/ParallelJsonArrayWriter.cs
new file mode 100644
index 0000000000..c0a4fa4bc4
--- /dev/null
+++ b/src/TUnit.Engine/Reporters/Aggregation/ParallelJsonArrayWriter.cs
@@ -0,0 +1,80 @@
+using System.Buffers;
+using System.Text.Json;
+
+namespace TUnit.Engine.Reporters.Aggregation;
+
+///
+/// Writes the elements of a large JSON array on several threads. Reports are serialized after the
+/// last test finishes, on the critical path of the run, while the thread pool is otherwise idle.
+/// Each slice of elements is written by its own into its own buffer and
+/// the slices are appended in order, so the output is byte-identical to writing the elements one by one.
+/// Source-linked into TUnit.Reporting.Tool alongside the serializers that use it.
+///
+internal static class ParallelJsonArrayWriter
+{
+ ///
+ /// Writes elements into the array that has just
+ /// opened with . must be the
+ /// writer's output and its options. The caller's next call on
+ /// must be .
+ ///
+ public static void WriteElements(
+ Utf8JsonWriter writer,
+ SegmentedBufferWriter buffer,
+ JsonWriterOptions options,
+ int count,
+ int minElementsPerSlice,
+ Action writeElement)
+ {
+ var sliceCount = Math.Min(Environment.ProcessorCount, count / minElementsPerSlice);
+ if (sliceCount < 2)
+ {
+ for (var i = 0; i < count; i++)
+ {
+ writeElement(writer, i);
+ }
+
+ return;
+ }
+
+ // Without validation a slice writer accepts several top-level values, and it still separates
+ // them with commas exactly as it would inside an array.
+ var sliceOptions = options;
+ sliceOptions.SkipValidation = true;
+ var slices = new SegmentedBufferWriter?[sliceCount];
+ try
+ {
+ Parallel.For(0, sliceCount, slice =>
+ {
+ var sliceBuffer = new SegmentedBufferWriter();
+ slices[slice] = sliceBuffer;
+ using var sliceWriter = new Utf8JsonWriter(sliceBuffer, sliceOptions);
+ var end = (int)((long)count * (slice + 1) / sliceCount);
+ for (var i = (int)((long)count * slice / sliceCount); i < end; i++)
+ {
+ writeElement(sliceWriter, i);
+ }
+ });
+
+ // Bytes appended to the buffer directly bypass the writer, which still considers the array
+ // empty. Closing the array needs no separator, which is why that must be the next call.
+ writer.Flush();
+ for (var slice = 0; slice < sliceCount; slice++)
+ {
+ if (slice > 0)
+ {
+ buffer.Write(","u8);
+ }
+
+ slices[slice]!.WriteTo(buffer);
+ }
+ }
+ finally
+ {
+ foreach (var slice in slices)
+ {
+ slice?.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/TUnit.Engine/Reporters/Aggregation/ReportDataJson.cs b/src/TUnit.Engine/Reporters/Aggregation/ReportDataJson.cs
index c912fc6a08..1bd179ed5d 100644
--- a/src/TUnit.Engine/Reporters/Aggregation/ReportDataJson.cs
+++ b/src/TUnit.Engine/Reporters/Aggregation/ReportDataJson.cs
@@ -138,8 +138,8 @@ internal static SegmentedBufferWriter SerializeToBuffer(ReportData data)
var buffer = new SegmentedBufferWriter();
try
{
- using var w = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false });
- Write(w, data);
+ using var w = new Utf8JsonWriter(buffer, WriterOptions);
+ Write(w, buffer, data);
}
catch
{
@@ -159,7 +159,14 @@ internal static byte[] SerializeToBytes(ReportData data)
internal static string Serialize(ReportData data)
=> Encoding.UTF8.GetString(SerializeToBytes(data));
- private static void Write(Utf8JsonWriter w, ReportData data)
+ private static readonly JsonWriterOptions WriterOptions = new() { Indented = false };
+
+ // Minimum elements a thread serializes when an array is split across threads (see
+ // ParallelJsonArrayWriter). Below twice these counts the array is written sequentially.
+ private const int MinGroupsPerSlice = 8;
+ private const int MinSpansPerSlice = 2000;
+
+ private static void Write(Utf8JsonWriter w, SegmentedBufferWriter buffer, ReportData data)
{
w.WriteStartObject();
w.WriteNumber("schemaVersion", SchemaVersion);
@@ -194,32 +201,37 @@ private static void Write(Utf8JsonWriter w, ReportData data)
w.WritePropertyName("groups");
w.WriteStartArray();
- foreach (var g in data.Groups)
- {
- w.WriteStartObject();
- w.WriteString("className", g.ClassName);
- w.WriteString("namespace", g.Namespace);
- w.WritePropertyName("summary");
- WriteSummary(w, g.Summary);
- w.WritePropertyName("tests");
- w.WriteStartArray();
- foreach (var t in g.Tests) WriteTest(w, t);
- w.WriteEndArray();
- w.WriteEndObject();
- }
+ var groups = data.Groups;
+ ParallelJsonArrayWriter.WriteElements(w, buffer, WriterOptions, groups.Length, MinGroupsPerSlice,
+ (writer, i) => WriteGroup(writer, groups[i]));
w.WriteEndArray();
if (data.Spans is { Length: > 0 } spans)
{
w.WritePropertyName("spans");
w.WriteStartArray();
- foreach (var s in spans) WriteSpan(w, s);
+ ParallelJsonArrayWriter.WriteElements(w, buffer, WriterOptions, spans.Length, MinSpansPerSlice,
+ (writer, i) => WriteSpan(writer, spans[i]));
w.WriteEndArray();
}
w.WriteEndObject();
}
+ private static void WriteGroup(Utf8JsonWriter w, ReportTestGroup g)
+ {
+ w.WriteStartObject();
+ w.WriteString("className", g.ClassName);
+ w.WriteString("namespace", g.Namespace);
+ w.WritePropertyName("summary");
+ WriteSummary(w, g.Summary);
+ w.WritePropertyName("tests");
+ w.WriteStartArray();
+ foreach (var t in g.Tests) WriteTest(w, t);
+ w.WriteEndArray();
+ w.WriteEndObject();
+ }
+
private static void WriteSummary(Utf8JsonWriter w, ReportSummary s)
{
w.WriteStartObject();
@@ -233,6 +245,10 @@ private static void WriteSummary(Utf8JsonWriter w, ReportSummary s)
w.WriteEndObject();
}
+ #if NET
+ // Runs once per process over every test, so tier-0 code would do the whole job unoptimized.
+ [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveOptimization)]
+#endif
private static void WriteTest(Utf8JsonWriter w, ReportTestResult t)
{
w.WriteStartObject();
@@ -313,6 +329,9 @@ private static void WriteException(Utf8JsonWriter w, ReportExceptionData ex)
w.WriteEndObject();
}
+ #if NET
+ [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveOptimization)]
+#endif
private static void WriteSpan(Utf8JsonWriter w, SpanData s)
{
w.WriteStartObject();
diff --git a/src/TUnit.Engine/Reporters/Aggregation/SegmentedBufferWriter.cs b/src/TUnit.Engine/Reporters/Aggregation/SegmentedBufferWriter.cs
index f8633486e9..90c40ac018 100644
--- a/src/TUnit.Engine/Reporters/Aggregation/SegmentedBufferWriter.cs
+++ b/src/TUnit.Engine/Reporters/Aggregation/SegmentedBufferWriter.cs
@@ -89,6 +89,20 @@ public void WriteTo(Stream destination)
}
}
+ /// Appends the written bytes to in order.
+ public void WriteTo(IBufferWriter destination)
+ {
+ for (var i = 0; i < _completed.Count; i++)
+ {
+ destination.Write(_completed[i].AsSpan(0, _completedCounts[i]));
+ }
+
+ if (_index > 0)
+ {
+ destination.Write(_current.AsSpan(0, _index));
+ }
+ }
+
public byte[] ToArray()
{
var result = new byte[checked((int)Length)];
diff --git a/src/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/src/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
index 6f6f4a510c..fbf9518255 100644
--- a/src/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
+++ b/src/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
@@ -96,6 +96,12 @@ private static string LoadTemplate()
// Returns the renderer JSON as UTF-8 in pooled chunks: it is only ever compressed, so it
// never needs to exist as one contiguous array or as a (twice as large) UTF-16 string.
// The caller owns (and must dispose) the result.
+ private static readonly JsonWriterOptions WriterOptions = new() { Indented = false };
+
+ // Minimum tests a thread serializes when the tests array is split across threads (see
+ // ParallelJsonArrayWriter). Below twice this count the array is written sequentially.
+ private const int MinTestsPerSlice = 1000;
+
private static SegmentedBufferWriter SerializeReport(ReportData data)
{
var totalTests = 0;
@@ -189,7 +195,7 @@ private static SegmentedBufferWriter SerializeReport(ReportData data)
var buffer = new SegmentedBufferWriter();
try
{
- using var w = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false });
+ using var w = new Utf8JsonWriter(buffer, WriterOptions);
w.WriteStartObject();
w.WriteString("project", data.AssemblyName);
w.WriteString("when", data.Timestamp);
@@ -218,16 +224,24 @@ private static SegmentedBufferWriter SerializeReport(ReportData data)
w.WriteNumber("wallMs", wallMs);
w.WriteNumber("workers", workers);
- w.WritePropertyName("tests");
- w.WriteStartArray();
+ var flatTests = new (ReportTestResult Test, ReportTestGroup Group)[totalTests];
+ var fi = 0;
foreach (var g in data.Groups)
{
foreach (var t in g.Tests)
{
- var startRel = absStartByTestId[t.Id] is { } a ? a - runStartMs : 0L;
- WriteTest(w, t, g, runStartMs, startRel, testWorker, spansByTrace);
+ flatTests[fi++] = (t, g);
}
}
+
+ w.WritePropertyName("tests");
+ w.WriteStartArray();
+ ParallelJsonArrayWriter.WriteElements(w, buffer, WriterOptions, totalTests, MinTestsPerSlice, (writer, i) =>
+ {
+ var (t, g) = flatTests[i];
+ var startRel = absStartByTestId[t.Id] is { } a ? a - runStartMs : 0L;
+ WriteTest(writer, t, g, runStartMs, startRel, testWorker, spansByTrace);
+ });
w.WriteEndArray();
#if NET
@@ -502,6 +516,10 @@ private static List BuildClassTimeline(Dictionary by
}
#endif
+ #if NET
+ // Runs once per process over every test, so tier-0 code would do the whole job unoptimized.
+ [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveOptimization)]
+#endif
private static void WriteTest(
Utf8JsonWriter w,
ReportTestResult t,
@@ -625,6 +643,9 @@ private static void WriteTraceSpans(
}
}
+ #if NET
+ [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveOptimization)]
+#endif
private static void WriteSpan(Utf8JsonWriter w, SpanData s, long runStartMs, bool linked = false)
{
w.WriteStartObject();
diff --git a/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index 53effb0436..1622dbebf6 100644
--- a/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/src/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -593,6 +593,10 @@ internal void SetResultsDirectory(string path)
_resultsDirectory = path;
}
+ #if NET
+ // Runs once per process over every test, so tier-0 code would do the whole job unoptimized.
+ [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveOptimization)]
+#endif
internal ReportData BuildReportData()
{
var assemblyName = GetAssemblyName();
@@ -871,6 +875,9 @@ private static DateTimeOffset ParseStartTimeForSort(string? raw)
: DateTimeOffset.MaxValue;
}
+ #if NET
+ [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveOptimization)]
+#endif
internal static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds, ReportAttempt[]? attempts = null, string? ciRepo = null, string? ciWorkspace = null)
{
IProperty? stateProperty = null;
diff --git a/src/TUnit.Reporting.Tool/TUnit.Reporting.Tool.csproj b/src/TUnit.Reporting.Tool/TUnit.Reporting.Tool.csproj
index d93878f287..dbd31f18ed 100644
--- a/src/TUnit.Reporting.Tool/TUnit.Reporting.Tool.csproj
+++ b/src/TUnit.Reporting.Tool/TUnit.Reporting.Tool.csproj
@@ -32,6 +32,7 @@
+
diff --git a/tests/TUnit.UnitTests/ParallelJsonArrayWriterTests.cs b/tests/TUnit.UnitTests/ParallelJsonArrayWriterTests.cs
new file mode 100644
index 0000000000..f9dfab1311
--- /dev/null
+++ b/tests/TUnit.UnitTests/ParallelJsonArrayWriterTests.cs
@@ -0,0 +1,70 @@
+using System.Text;
+using System.Text.Json;
+using TUnit.Engine.Reporters.Aggregation;
+
+namespace TUnit.UnitTests;
+
+public class ParallelJsonArrayWriterTests
+{
+ private static readonly JsonWriterOptions Options = new() { Indented = false };
+
+ [Test]
+ [Arguments(0, 1)]
+ [Arguments(1, 1)]
+ [Arguments(7, 1)]
+ [Arguments(10_000, 10)]
+ [Arguments(10_000, 5_000)]
+ [Arguments(10_000, 100_000)]
+ public async Task Output_Matches_Sequential_Serialization(int count, int minElementsPerSlice)
+ {
+ var expected = Serialize(count, minElementsPerSlice: null);
+ var actual = Serialize(count, minElementsPerSlice);
+
+ await Assert.That(actual).IsEqualTo(expected);
+ using var document = JsonDocument.Parse(actual);
+ await Assert.That(document.RootElement.GetProperty("items").GetArrayLength()).IsEqualTo(count);
+ }
+
+ // Writes an object whose "items" array is filled one element at a time when
+ // minElementsPerSlice is null, and through ParallelJsonArrayWriter otherwise.
+ private static string Serialize(int count, int? minElementsPerSlice)
+ {
+ using var buffer = new SegmentedBufferWriter();
+ using (var w = new Utf8JsonWriter(buffer, Options))
+ {
+ w.WriteStartObject();
+ w.WriteString("before", "value");
+ w.WritePropertyName("items");
+ w.WriteStartArray();
+ if (minElementsPerSlice is { } min)
+ {
+ ParallelJsonArrayWriter.WriteElements(w, buffer, Options, count, min, WriteElement);
+ }
+ else
+ {
+ for (var i = 0; i < count; i++)
+ {
+ WriteElement(w, i);
+ }
+ }
+ w.WriteEndArray();
+ w.WriteString("after", "value");
+ w.WriteEndObject();
+ }
+
+ return Encoding.UTF8.GetString(buffer.ToArray());
+ }
+
+ private static void WriteElement(Utf8JsonWriter w, int i)
+ {
+ w.WriteStartObject();
+ w.WriteNumber("index", i);
+ w.WriteString("name", $"Test{i} \"quoted\" \\ ");
+ w.WritePropertyName("nested");
+ w.WriteStartArray();
+ w.WriteNumberValue(i * 0.5);
+ w.WriteNullValue();
+ w.WriteEndArray();
+ w.WriteEndObject();
+ }
+}