From c558d5e8559d3d7491df81f9018b2c7342f91a78 Mon Sep 17 00:00:00 2001 From: Ciaran Liedeman Date: Sat, 27 Jun 2026 22:20:03 +0200 Subject: [PATCH] perf(strawberry-shake): format single-file output per namespace GenerateSingleCSharpDocument built one CompilationUnit of every generated type and ran NormalizeWhitespace(elasticTrivia: true) on the whole tree. For a large client this was ~75% of generate time and scaled super-linearly: on the ILOPS op set, generate was ~88 s at 200 ops and ~182 s at 275 ops, and the single huge tree was the out-of-memory source. Two costs dominated, both inside this method: - Adding types to the namespace one at a time (AddMembers per type) copied the growing member list on every call, which is quadratic for a namespace with tens of thousands of types (one client had ~34k types in a single namespace). - NormalizeWhitespace over the whole multi-namespace tree. This change collects each namespace's members and sets them once with WithMembers(List(...)) instead of repeated AddMembers, and normalizes each namespace declaration on its own, joining the pieces with the same blank-line separator the whole-tree normalizer emits between top-level namespace members. It never materializes one tree for the whole client. Output is byte-identical. A standalone namespace declaration normalizes with no leading or trailing trivia, and normalizing each namespace as a real subtree preserves the normalizer's own handling of nested constructs such as preprocessor directives. All 189 generator/integration snapshot tests pass unchanged. Before/after (generate, warm, ILOPS ops): - N=200: 88,315 ms -> ~21,600 ms (~4.1x) - N=275: 181,956 ms -> ~40,000 ms (~4.6x); completes reliably, no OOM Relates to #17. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CodeGeneration.CSharp/CSharpGenerator.cs | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/StrawberryShake/CodeGeneration/src/CodeGeneration.CSharp/CSharpGenerator.cs b/src/StrawberryShake/CodeGeneration/src/CodeGeneration.CSharp/CSharpGenerator.cs index 9d1b9f01c85..0f0a3b8e4b3 100644 --- a/src/StrawberryShake/CodeGeneration/src/CodeGeneration.CSharp/CSharpGenerator.cs +++ b/src/StrawberryShake/CodeGeneration/src/CodeGeneration.CSharp/CSharpGenerator.cs @@ -4,6 +4,7 @@ using HotChocolate.Language; using HotChocolate.Validation; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Formatting; using Microsoft.Extensions.DependencyInjection; using StrawberryShake.CodeGeneration.Analyzers; @@ -262,13 +263,31 @@ private static void GenerateSingleCSharpDocument( code.AppendLine("#nullable enable annotations"); code.AppendLine("#nullable disable warnings"); - var compilationUnit = CompilationUnit(); + code.AppendLine(); + + // Each namespace declaration is normalized on its own and the pieces are joined, + // rather than normalizing one giant compilation unit of every namespace. Building + // that single tree was super-linear in the number of types and dominated generation + // for large clients (it was also the out-of-memory source, since it materialized one + // huge tree). Normalizing per namespace keeps the cost far lower and never holds the + // whole client in a single tree. + // + // The assembled text is byte-identical to normalizing the whole compilation unit. A + // standalone namespace declaration normalizes with no leading or trailing trivia, and + // the whole-tree normalizer separates the top-level namespace members with a blank + // line (one line ending to close the previous namespace's brace, then one blank + // line), so we reproduce exactly that separator between namespaces. Normalizing each + // namespace as a real subtree (rather than re-indenting per-type text) preserves the + // normalizer's own handling of nested constructs such as preprocessor directives. + var firstNamespace = true; foreach (var group in results.GroupBy(t => t.Result.Namespace).OrderBy(t => t.Key)) { var namespaceDeclaration = NamespaceDeclaration(IdentifierName(group.Key)); + var members = new List(); + foreach (var item in group) { var typeDeclaration = item.Result.TypeDeclaration; @@ -279,16 +298,37 @@ private static void GenerateSingleCSharpDocument( typeDeclaration = typeDeclaration.WithLeadingTrivia(trivia); #endif - namespaceDeclaration = namespaceDeclaration.AddMembers(typeDeclaration); + members.Add(typeDeclaration); } - compilationUnit = compilationUnit.AddMembers(namespaceDeclaration); - } + // Adding the members one at a time copies the growing member list on every call, + // which is quadratic for namespaces with tens of thousands of types. Setting the + // whole list once keeps namespace construction linear. + namespaceDeclaration = namespaceDeclaration.WithMembers(List(members)); - compilationUnit = compilationUnit.NormalizeWhitespace(elasticTrivia: true); + var normalized = namespaceDeclaration + .NormalizeWhitespace(elasticTrivia: true) + .ToFullString(); - code.AppendLine(); - code.AppendLine(compilationUnit.ToFullString()); + if (firstNamespace) + { + firstNamespace = false; + } + else + { + code.Append("\r\n\r\n"); + } + + code.Append(normalized); + } + + // The original whole-tree path appended a trailing line ending after the compilation + // unit (AppendLine of ToFullString()). Preserve it so the emitted text stays + // byte-identical. + if (!firstNamespace) + { + code.Append(Environment.NewLine); + } documents.Add( new(