Summary
The C# emitter (@typespec/http-client-csharp) spends roughly 70-80% of its CSharpGen.ExecuteAsync wall-clock time inside the Roslyn post-processing block (GeneratedCodeWorkspace.PostProcessAsync + ProcessDocument). The biggest single contributor is Simplifier.ReduceAsync. This issue tracks several concrete optimizations.
Measurements
Captured via the existing LoggingHelpers.LogElapsedTime markers + a temporary per-call Stopwatch around Simplifier.ReduceAsync in GeneratedCodeWorkspace.ProcessDocument:
| Project |
# generated files |
Total ExecuteAsync |
Roslyn block |
Σ Simplifier.ReduceAsync CPU |
Avg per file |
| Local/Sample-TypeSpec |
138 |
9.10 s |
7.53 s |
148.4 s |
~1.07 s/file |
| Spector payload/json-merge-patch |
30 |
5.36 s |
3.79 s |
8.92 s |
297 ms |
| Spector type/model/inheritance/nested-discriminator |
38 |
5.12 s |
3.95 s |
15.66 s |
412 ms |
| Spector type/enum/extensible |
26 |
3.87 s |
2.87 s |
3.96 s |
152 ms |
Σ Reduce CPU vastly exceeds wall-clock because ProcessDocument runs in Task.WhenAll, but the Roslyn semantic-model lock serializes most of it, so reducing this CPU lowers wall-clock nearly proportionally.
Root causes
-
Whole-tree Simplifier.Annotation. GeneratedCodeWorkspace.UpdateProject (line ~93) and PostProcessor.MarkInternal (line ~343) call WithAdditionalAnnotations(Simplifier.Annotation) on the entire syntax root. This forces Simplifier.ReduceAsync to walk every node with a full semantic model and probe each reducer (CSharpAliasReducer, CSharpNameReducer, CSharpCastReducer, CSharpExtensionMethodReducer, CSharpParenthesesReducer).
-
Internalization re-annotates unnecessarily. MarkInternal only flips a public keyword to internal; nothing inside the type becomes reducible. The re-applied annotation triggers redundant work on the second ReduceAsync pass.
-
Duplicated InternalizeAsync + RemoveAsync. When UnreferencedTypesHandling = RemoveOrInternalize (the common case), both run in sequence. Each re-binds the compilation, re-walks every syntax tree, and re-runs SymbolFinder.FindReferencesAsync over every type. The Remove pass's reference graph is a strict superset of the Internalize pass's.
-
Sequential SymbolFinder.FindReferencesAsync in ReferenceMapBuilder.BuildPublicReferenceMapAsync / BuildAllReferenceMapAsync (sequential foreach … await over every declared symbol).
-
Per-node WithSyntaxRoot in InternalizeAsync (foreach (var (model, documentId) in nodesToInternalize) project = MarkInternal(...)) invalidates the compilation cache between every node.
-
GetSemanticModelAsync in ProcessDocument runs unconditionally, even when there are no rewriters and Simplifier.ReduceAsync is the only consumer.
Proposed optimizations (ranked by leverage)
- Targeted
Simplifier.Annotation. Annotate only the actual FQN nodes (AliasQualifiedNameSyntax rooted in global:: and their topmost QualifiedNameSyntax ancestor). Drop the root annotation in UpdateProject and MarkInternal. Expected to reduce Simplifier.ReduceAsync cost by an order of magnitude on large libraries.
- Drop
Simplifier.Annotation in MarkInternal entirely (no reducible content is introduced by an accessibility flip).
- Skip
Simplifier.ReduceAsync on documents with zero annotated nodes.
- Merge
InternalizeAsync + RemoveAsync into a single pass that builds one all-references map and derives both sets.
- Parallelize
SymbolFinder.FindReferencesAsync with Parallel.ForEachAsync and a ConcurrentDictionary-backed ReferenceMap.
- Batch
MarkInternal per document (one WithSyntaxRoot per document, not per node).
- Skip
GetSemanticModelAsync in ProcessDocument when no rewriters are registered and the document has no reducible annotations.
- Long term: emit short type names +
usings in CodeWriter.AppendType so Simplifier becomes a no-op and can be removed entirely (with the semantic-model fetch).
Reproduction
Add a Stopwatch around Simplifier.ReduceAsync in packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs ProcessDocument and emit the cumulative time alongside the existing "Roslyn post processing complete" log line. Run npx tsp compile <spec> --trace @typespec/http-client-csharp --emit . --option @typespec/http-client-csharp.emitter-output-dir=<out> against any of the test projects above.
Summary
The C# emitter (
@typespec/http-client-csharp) spends roughly 70-80% of itsCSharpGen.ExecuteAsyncwall-clock time inside the Roslyn post-processing block (GeneratedCodeWorkspace.PostProcessAsync+ProcessDocument). The biggest single contributor isSimplifier.ReduceAsync. This issue tracks several concrete optimizations.Measurements
Captured via the existing
LoggingHelpers.LogElapsedTimemarkers + a temporary per-callStopwatcharoundSimplifier.ReduceAsyncinGeneratedCodeWorkspace.ProcessDocument:ExecuteAsyncSimplifier.ReduceAsyncCPUΣ Reduce CPU vastly exceeds wall-clock because
ProcessDocumentruns inTask.WhenAll, but the Roslyn semantic-model lock serializes most of it, so reducing this CPU lowers wall-clock nearly proportionally.Root causes
Whole-tree
Simplifier.Annotation.GeneratedCodeWorkspace.UpdateProject(line ~93) andPostProcessor.MarkInternal(line ~343) callWithAdditionalAnnotations(Simplifier.Annotation)on the entire syntax root. This forcesSimplifier.ReduceAsyncto walk every node with a full semantic model and probe each reducer (CSharpAliasReducer,CSharpNameReducer,CSharpCastReducer,CSharpExtensionMethodReducer,CSharpParenthesesReducer).Internalization re-annotates unnecessarily.
MarkInternalonly flips apublickeyword tointernal; nothing inside the type becomes reducible. The re-applied annotation triggers redundant work on the secondReduceAsyncpass.Duplicated
InternalizeAsync+RemoveAsync. WhenUnreferencedTypesHandling = RemoveOrInternalize(the common case), both run in sequence. Each re-binds the compilation, re-walks every syntax tree, and re-runsSymbolFinder.FindReferencesAsyncover every type. The Remove pass's reference graph is a strict superset of the Internalize pass's.Sequential
SymbolFinder.FindReferencesAsyncinReferenceMapBuilder.BuildPublicReferenceMapAsync/BuildAllReferenceMapAsync(sequentialforeach … awaitover every declared symbol).Per-node
WithSyntaxRootinInternalizeAsync(foreach (var (model, documentId) in nodesToInternalize) project = MarkInternal(...)) invalidates the compilation cache between every node.GetSemanticModelAsyncinProcessDocumentruns unconditionally, even when there are no rewriters andSimplifier.ReduceAsyncis the only consumer.Proposed optimizations (ranked by leverage)
Simplifier.Annotation. Annotate only the actual FQN nodes (AliasQualifiedNameSyntaxrooted inglobal::and their topmostQualifiedNameSyntaxancestor). Drop the root annotation inUpdateProjectandMarkInternal. Expected to reduceSimplifier.ReduceAsynccost by an order of magnitude on large libraries.Simplifier.AnnotationinMarkInternalentirely (no reducible content is introduced by an accessibility flip).Simplifier.ReduceAsyncon documents with zero annotated nodes.InternalizeAsync+RemoveAsyncinto a single pass that builds one all-references map and derives both sets.SymbolFinder.FindReferencesAsyncwithParallel.ForEachAsyncand aConcurrentDictionary-backedReferenceMap.MarkInternalper document (oneWithSyntaxRootper document, not per node).GetSemanticModelAsyncinProcessDocumentwhen no rewriters are registered and the document has no reducible annotations.usings inCodeWriter.AppendTypesoSimplifierbecomes a no-op and can be removed entirely (with the semantic-model fetch).Reproduction
Add a
StopwatcharoundSimplifier.ReduceAsyncinpackages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.csProcessDocumentand emit the cumulative time alongside the existing"Roslyn post processing complete"log line. Runnpx tsp compile <spec> --trace @typespec/http-client-csharp --emit . --option @typespec/http-client-csharp.emitter-output-dir=<out>against any of the test projects above.