perf(strawberry-shake): format single-file output per namespace#19
Open
cliedeman wants to merge 1 commit into
Open
perf(strawberry-shake): format single-file output per namespace#19cliedeman wants to merge 1 commit into
cliedeman wants to merge 1 commit into
Conversation
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hotspot
With
SingleCodeFile=true(the default),CSharpGenerator.GenerateSingleCSharpDocumentbuilt one giantCompilationUnitof every generated type and calledcompilationUnit.NormalizeWhitespace(elasticTrivia: true)on the whole tree. On the ILOPS operation set this was the dominant cost of code generation and scaled super-linearly:generate~88 sgenerate~182 sThe single huge tree was also the out-of-memory source for the full client.
Instrumenting the method showed two distinct super-linear costs, both inside it:
namespaceDeclaration.AddMembers(typeDeclaration)in a loop). Each call on the immutable Roslyn node copies the growing member list, so a namespace with tens of thousands of types is quadratic. This client puts ~34k types in a single namespace, and this step alone was ~27 s at N=200.NormalizeWhitespaceover the multi-namespace tree (~13 s at N=200).Approach
Output-identical reformatting (no behaviour change to the emitted source):
WithMembers(List(...))instead of repeatedAddMembers. Namespace construction becomes linear (this removed the larger of the two costs).A standalone namespace declaration normalizes with no leading or trailing trivia, so the joined text reconstructs the whole-tree output exactly. Normalizing each namespace as a real Roslyn subtree (rather than re-indenting per-type strings) is what makes this byte-identical: it preserves the normalizer's own handling of nested constructs, in particular preprocessor directives (
#if NETCOREAPP3_1_OR_GREATERin the generatedOperationDocumenttypes), which the normalizer places in a way that a naive per-type re-indent cannot reproduce.Output impact
Byte-identical. No snapshots change. All 189 generator and integration snapshot tests in
CodeGeneration.CSharp.Testspass unchanged (1 skipped), including every full-client*Test.Client.csintegration snapshot.A per-type normalize was prototyped and measured (it would push the remaining normalize cost to linear and lower peak memory further), but it changes the emitted text for types containing preprocessor directives, so it was rejected in favour of the byte-identical per-namespace approach.
Before / after
generatems on the ILOPS op set (warm, schema reused):N=275 now completes reliably; the single-huge-tree OOM source is gone (no client-sized tree is ever built).
Relates to #17— Claude
🤖 Generated with Claude Code