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(); + } +}