Skip to content

Speed up graph building for compositions with many generic factory instantiations - #161

Closed
ekalchev wants to merge 2 commits into
DevTeam:masterfrom
ekalchev:perf-generic-factories
Closed

ekalchev wants to merge 2 commits into
DevTeam:masterfrom
ekalchev:perf-generic-factories

Conversation

@ekalchev

Copy link
Copy Markdown

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> and Func<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 csc shows almost all of it under VariationalDependencyGraphBuilder.Build → DependencyGraphBuilder.Build, in two places:

  1. FactoryTypeRewriter. A generic factory is rewritten for every closed type it is resolved with. Each rewrite calls SemanticModel.GetSymbolInfo on 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. GetSymbolInfo inside lambdas was the largest single cost of the whole generation.
  2. Generic candidate lookup. For each generic injection that is not in the map, DependencyGraphBuilder.Build runs map.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.md lists 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.

FactoryTypeRewriter reads through a new PerResolve FactoryRewriteCache, which keeps for one generation:

  • the type symbol of a name, per (semantic model, syntax node), and
  • the rewritten 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. The else branch 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:

  • The untagged 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.
  • The Local cache is Transient, so each rewriter would get its own.
  • Semantic is PerBlock and is created again on every Func call.

A PerResolve instance is created once per Generate root resolution, like TypeResolver.

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:

  • OrderByDescending is stable, so ties kept map order. List.Sort is not, so each entry carries its position and the list is sorted by binding id descending, then position ascending.
  • The skip predicates are pure, so TryBind sees exactly the same candidate sequence and the type constructor ends in the same state.
  • Inside the loop the map changes only through UpdateMap, and each change is followed by break.

TryGetSpanConversionSource has the same whole-map ordering. It runs only for span types, so it is left as it is.

Data

Generator time is the Pure.DI line of csc /reportanalyzer, on the same compilation. Each step was run in interleaved rounds on one machine, because single runs vary by up to ±30% there.

Step Generator time
master eef3d79 154–167 s
+ symbol cache (change 1, first half) 91–113 s
+ candidate filter (change 2, before the list was reused) 73–87 s
+ reused candidate list (change 2) 64–76 s
+ type syntax cache (change 1, second half) 51–62 s, median about 55 s

The generated composition is identical to master's at every step, apart from the three lines that stamp the generator version.

The existing codegen-perf profiles do not exercise change 1. GenericsAndVariants binds .To<GenericProfileRepository<TT>>(), which is not a factory, so FactoryTypeRewriter never runs on it. I can add a Composition.tt profile with a generic factory closed over many types and many Func<T>/Owned<T> injections, if you want this measured on your harness.

Tests

  • New 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).

Emil Kalchev added 2 commits October 14, 2026 00:31
… 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.
@ekalchev

Copy link
Copy Markdown
Author

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 .To<T>() had a second constructor that the composition could see but not resolve:

  • a test constructor, visible to the host through InternalsVisibleTo;
  • an overload taking a double.

VariationalDependencyGraphBuilder kept retrying constructor combinations. It also started them over each time a build discovered new nodes. The two changes here made each of those builds about three times cheaper, roughly 100 ms down to 37 ms, which is where 155 s → 55 s came from.

With those two constructors marked and that attribute registered via .OrdinalAttribute<T>(), the graph is built once. On that composition, generation then takes:

Generator Time
unmodified master 2.3–3.8 s
this branch 2.4–2.5 s

So I can't show a gain worth reviewing. Measured on your codegen-perf profiles, it would likely fall under your 5% threshold. The per-build improvement is real, but not on the scale this PR claims.

Two things we ran into may be worth a separate issue:

  • The variant loop is silent. An unresolvable constructor costs one full graph rebuild per combination, with no diagnostic.
  • OverloadResolutionPriority is dropped. Pure.DI reads the attribute's priority when it creates the implementation variants. DependencyNodePrioritizer.SortByPriority then re-sorts them by ordinal and required-parameter count, so the priority has no effect. A constructor marked [OverloadResolutionPriority(1)] was still tried after the overload with more parameters.

Thanks for the quick merges of #158 and the follow-up fixes.

@ekalchev ekalchev closed this Oct 14, 2026
@NikolayPianikov

Copy link
Copy Markdown
Member

@ekalchev The performance issue may be due to a large number of uses of Func<>. If possible, please send me the results of the code generation trace. Or an example of a project with such a composition — for example, you can ask LLM to do this in the HugeComposition project. I’ll think about how to reproduce this on my test environment and how to fix it better.

@ekalchev

ekalchev commented Oct 14, 2026 •

Copy link
Copy Markdown
Author

@NikolayPianikov Thanks for looking into it.

Where we are now:

  • Generation time is solved on our side. As described above, two classes had a constructor the graph could not resolve, and the variant loop rebuilt the whole graph 1,542 times. With the right constructors marked, generation takes about 2.4 s.
  • The generated composition is still large: about 13,000 lines (~1 MB) for a single host. Our composition is certainly not optimal, because we are porting it from another container.

On Func<>:

  • We have a lot of Func<> and would like to refactor them where possible.
  • Some take an argument (Func<TArg, T>), and I don't think we can remove all of those. In places the argument is a value only the calling code has at run time, such as a dialog's input.
  • Where we can, we will turn such values into method arguments on objects the container builds, or into root arguments.

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.

@ekalchev

Copy link
Copy Markdown
Author

@NikolayPianikov One more question about the Func<TArg, T> cases.

Background: earlier we had a root typed as a delegate, .Root<Func<TArg, MainWindow>>("CreateMainWindow"). Replacing it with a method root, .RootArg<TArg>("arg").Root<MainWindow>("CreateMainWindow"), cut the generated composition a lot. The reason is that a Func with arguments overrides its argument for everything it builds, so no shared singleton helpers are generated inside it.

That makes me wonder whether we should do the same for the remaining Func<TArg, T> injections, mostly dialogs: Func<TInput, Owned<TViewModel>>, where the input is a value the calling code has at run time.

  1. Is a root with arguments generally better than injecting a Func<TArg, T>, for both generation time and the size of the generated code?
  2. Are there drawbacks to having many roots, say a few dozen method roots with root arguments, compared with the same number of Func<TArg, T>? For example: duplicated code per root, or root arguments shared across all roots.
  3. A consumer cannot take a root as a constructor dependency without going through the composition, which we want to avoid. Is there a recommended way to hand a root with arguments to the class that needs it?

@ekalchev

Copy link
Copy Markdown
Author

@NikolayPianikov Here are the code generation traces you asked for, captured with PureDIProfilePath (dotTrace 2024.3.3, sampling).

Setup:

  • Pure.DI built from master at eef3d79 (local package 2.5.5-master.eef3d79), .NET SDK 10.0.112.
  • One composition, for a WinUI 3 host.
  • The generated file is 13,077 lines (~1 MB) in both runs, byte-identical.

Traces:

  • pure-di-trace-slow.zip: the composition before our fix. Two classes have a second constructor the graph cannot resolve, and no .OrdinalAttribute<>() is registered. The generator takes 198 s. The snapshot covers only the first 30 s, because the profiler stops at --timeout=30s.
  • pure-di-trace-fast.zip: the same composition with those constructors marked and the attribute registered. The generator takes 4.8 s, including the 2 s the profiler waits to attach.

Each zip holds one multi-file snapshot. Extract all of its files into one folder and open the .dtt.

@NikolayPianikov

Copy link
Copy Markdown
Member

@ekalchev Thank you very much! This is very valuable information; I’ll try to figure it out soon.
It would be interesting to explore the specific option you had that led to the generator running for a long time, in order to optimize the search through the options. I’d like the generation speed not to drop so catastrophically under any configuration.

Regarding your questions, I promise to respond in sequence soon. The main rules I follow in large projects are:

  • use Transient lifetime whenever possible. Other lifetimes involve additional logic that reduces efficiency. Most likely, the application requires only a small number of Singletons, which in turn will make their dependencies Singletons transitively.
  • a minimum of factories with custom object initialization; let the DI decide what to inject; complex dependencies are resolved using tags.

@NikolayPianikov

Copy link
Copy Markdown
Member

Here’s an example, it contains quite large compositions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants