Repository navigation
Conversation
… each rewritten type once A generic factory is rewritten for every closed type it is resolved with. For each rewrite, FactoryTypeRewriter asked the semantic model for the symbol of every name in the factory, and parsed a new type syntax for every name it rewrites. On a composition with many Func<T>, Owned<T> and Func<TArg, T> injections, GetSymbolInfo inside the factory lambdas was the largest single cost of generation. The symbol depends only on the semantic model and the node, and the type syntax only on the node and the rewritten type name, so both are kept in a PerResolve cache that lives for one generation. Marker and closed-type decisions are not cached. Measured on a WinUI composition of about 1 MB of generated code (interleaved runs): the symbol cache took the generator from 154-167 s to 91-113 s; the type syntax cache, on top of the next commit, from 64-76 s to 51-62 s. The generated code is unchanged.
…used list For a generic injection that is not in the map, the graph builder ordered a copy of the whole injection map by binding id and then skipped the entries whose key is not a named type, differs in being generic, or has another tag. The map grows with every resolved injection, so on large compositions each such injection copied and sorted thousands of entries into a new array. Those entries are now skipped before ordering, into one list reused for every injection. The order is unchanged: newest binding first, map order among equal ids. Measured on the same composition, with the symbol cache of the previous commit in place: 91-113 s before, 73-87 s with the filter, 64-76 s with the reused list. The generated code is unchanged.
|
Closing this: the numbers in the description overstate what these changes are worth. Instrumenting the generator on the same composition showed that most of the 154–167 s was not the cost of one graph build. The dependency graph was being built 1,542 times per generation. Two classes bound with
With those two constructors marked and that attribute registered via
So I can't show a gain worth reviewing. Measured on your Two things we ran into may be worth a separate issue:
Thanks for the quick merges of #158 and the follow-up fixes. |
|
@ekalchev The performance issue may be due to a large number of uses of |
|
@NikolayPianikov Thanks for looking into it. Where we are now:
On
Are there guidelines on what to avoid and what to do instead, for both generation time and the size of the generated code? It would help AI agents not to have to discover these things by debugging the code. |
|
@NikolayPianikov One more question about the Background: earlier we had a root typed as a delegate, That makes me wonder whether we should do the same for the remaining
|
|
@NikolayPianikov Here are the code generation traces you asked for, captured with Setup:
Traces:
Each zip holds one multi-file snapshot. Extract all of its files into one folder and open the |
|
@ekalchev Thank you very much! This is very valuable information; I’ll try to figure it out soon. Regarding your questions, I promise to respond in sequence soon. The main rules I follow in large projects are:
|
|
Here’s an example, it contains quite large compositions |
Problem
On a large real-world composition, the generator takes 154–167 s per compilation on current master (eef3d79). The project is a WinUI 3 desktop application whose composition generates about 1 MB of code and uses many
Func<T>,Owned<T>andFunc<TArg, T>injections and generic marker bindings. A XAML project compiles its C# twice, so the build pays this twice.The generated code is small after #158, so the time is not in emitting it. A sampling profile of
cscshows almost all of it underVariationalDependencyGraphBuilder.Build→DependencyGraphBuilder.Build, in two places:FactoryTypeRewriter. A generic factory is rewritten for every closed type it is resolved with. Each rewrite callsSemanticModel.GetSymbolInfoon every name in the factory lambda, and builds a new type syntax (SyntaxFactory.ParseTypeName(...).WithLeadingTrivia(...).WithTrailingTrivia(...)) for every name it rewrites. Both answers repeat for every instantiation.GetSymbolInfoinside lambdas was the largest single cost of the whole generation.DependencyGraphBuilder.Buildrunsmap.OrderByDescending(i => i.Value.Binding.Id)over the whole injection map, then skips entries by tag and genericity. The map grows with every resolved injection, so each lookup copies and sorts more entries. In the profile, about half the time of that ordering was spent waiting for garbage collection while copying.The second is the route
samples/HugeComposition/PERFORMANCE_OPTIMIZATION_PLAN.mdlists as "repeated scans of maps for generic and marker-based bindings".Changes
1. Look up each name of a generic factory once per generation, and parse each rewritten type once.
FactoryTypeRewriterreads through a new PerResolveFactoryRewriteCache, which keeps for one generation:TypeSyntax, per (syntax node, rewritten type name).Syntax nodes are immutable and the rewriter rebuilds parents from the node it returns, so one node serves every rewrite of the same name to the same type. Marker and closed-type decisions are not cached; they still go through the current
TypeConstructor. Theelsebranch for nodes from another tree (GetTypeByMetadataName, used for factories the generator synthesises itself) is unchanged.Why a dedicated PerResolve cache rather than an existing one:
ICache<,>is a Singleton, so it would keep syntax trees and semantic models for a whole IDE session. That breaks the plan's rule not to cache Roslyn symbols beyond their compilation.Localcache is Transient, so each rewriter would get its own.Semanticis PerBlock and is created again on everyFunccall.A PerResolve instance is created once per
Generateroot resolution, likeTypeResolver.2. Order only the bindings that can serve a generic injection, in one reused list.
The entries the loop skipped anyway (key not a named type, a different genericity, another tag) are left out before ordering. The candidates go into one list that is reused for every injection of the build.
The order is unchanged:
OrderByDescendingis stable, so ties kept map order.List.Sortis not, so each entry carries its position and the list is sorted by binding id descending, then position ascending.TryBindsees exactly the same candidate sequence and the type constructor ends in the same state.UpdateMap, and each change is followed bybreak.TryGetSpanConversionSourcehas the same whole-map ordering. It runs only for span types, so it is left as it is.Data
Generator time is the
Pure.DIline ofcsc /reportanalyzer, on the same compilation. Each step was run in interleaved rounds on one machine, because single runs vary by up to ±30% there.The generated composition is identical to master's at every step, apart from the three lines that stamp the generator version.
The existing
codegen-perfprofiles do not exercise change 1.GenericsAndVariantsbinds.To<GenericProfileRepository<TT>>(), which is not a factory, soFactoryTypeRewriternever runs on it. I can add aComposition.ttprofile with a generic factory closed over many types and manyFunc<T>/Owned<T>injections, if you want this measured on your harness.Tests
FactoryRewriteCacheTests: the type of a name, no type for the name of a value, the same syntax instance for the same name and type, trivia kept, and one syntax per type.Pure.DI.Tests: 43/43.Pure.DI.IntegrationTests: 1887/1888 (the one existing skip).