Skip to content

perf(strawberry-shake): format single-file output per namespace#19

Open
cliedeman wants to merge 1 commit into
fork-ci-basefrom
perf/ss-single-file-format
Open

perf(strawberry-shake): format single-file output per namespace#19
cliedeman wants to merge 1 commit into
fork-ci-basefrom
perf/ss-single-file-format

Conversation

@cliedeman

Copy link
Copy Markdown

Hotspot

With SingleCodeFile=true (the default), CSharpGenerator.GenerateSingleCSharpDocument built one giant CompilationUnit of every generated type and called compilationUnit.NormalizeWhitespace(elasticTrivia: true) on the whole tree. On the ILOPS operation set this was the dominant cost of code generation and scaled super-linearly:

  • N=200 ops: generate ~88 s
  • N=275 ops: generate ~182 s

The 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:

  1. Building the namespace tree. Types were added to the namespace one at a time (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.
  2. Whole-tree NormalizeWhitespace over the multi-namespace tree (~13 s at N=200).

Approach

Output-identical reformatting (no behaviour change to the emitted source):

  • Collect each namespace's members and set them once with WithMembers(List(...)) instead of repeated AddMembers. Namespace construction becomes linear (this removed the larger of the two costs).
  • Normalize each namespace declaration on its own and join the pieces with the same blank-line separator the whole-tree normalizer emits between top-level namespace members (one line ending to close the previous namespace's brace, then one blank line). We never materialize one tree for the whole client.

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_GREATER in the generated OperationDocument types), 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.Tests pass unchanged (1 skipped), including every full-client *Test.Client.cs integration 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

generate ms on the ILOPS op set (warm, schema reused):

N (ops) before after speedup
200 88,315 ms ~21,600 ms ~4.1x
275 181,956 ms ~40,000 ms ~4.6x

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

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant