📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

πŸ“š Interview question

What makes a source generator incremental, and how do you keep a generator from slowing down the IDE?

Incremental pipelines, caching and equality, ForAttributeWithMetadataName, avoiding symbols and syntax in models, and measuring generator performance.

Senior Theory Expert incremental-generators performance roslyn source-generators

🎯 Key points

  • Incremental generators build a pipeline of cached transformation steps
  • Steps rerun only when their inputs change, judged by equality
  • Models must be cheap value-equal types, never ISymbol, SyntaxNode or Compilation
  • Wrap ImmutableArray in an equatable array for value comparison
  • ForAttributeWithMetadataName is much faster than scanning all syntax
  • Collect() reruns the whole step on any change; prefer per-item output
  • Test incrementality with tracked steps and measure with ReportAnalyzer

⏱ 30-second answer

An incremental generator declares a pipeline of transformations (Select, Where, Combine, Collect) over providers such as SyntaxProvider, CompilationProvider and AdditionalTextsProvider. Roslyn caches each step's output and, on every keystroke, reruns a step only if its input changed, comparing values with equality. So the models flowing through the pipeline must be cheap, value-equal types (records of strings, an equatable array wrapper), never ISymbol, SyntaxNode or Compilation, which change every edit and defeat caching. Use ForAttributeWithMetadataName for attribute-driven generators: it is far faster than scanning every syntax node. A slow or non-incremental generator makes typing in the IDE lag.

πŸ—£ Full answer (senior)

How I would answer this in an interview

An incremental source generator is one whose work is expressed as a pipeline of cached steps, so that when a developer types, Roslyn re-runs only the steps whose inputs changed β€” and skips the rest by comparing step outputs for equality. The IDE runs generators continuously (on almost every keystroke), so a non-incremental or poorly designed generator directly slows down IntelliSense, typing and builds. The key techniques are: fast syntactic filtering (ideally ForAttributeWithMetadataName), value-equatable models, avoiding the whole Compilation in the pipeline, and testing incrementality.

1. How incrementality works

SyntaxProvider (attributed classes)  ──Select──>  TypeModel (record of strings)  ──>  RegisterSourceOutput
          ↑ re-runs only for changed files        ↑ cached if equal to last run         ↑ skipped if model unchanged
  • Each node in the pipeline caches its outputs from the previous run.
  • When input changes (a file edit), Roslyn recomputes affected items, then compares new outputs with cached ones using Equals (or a custom comparer).
  • If outputs are equal, downstream steps don't run β€” including source generation and the expensive recompilation that follows when generated text changes.

2. What makes a generator incremental (in practice)

1. Implement IIncrementalGenerator (not the old ISourceGenerator, which runs everything every time).

2. Filter early and cheaply:

context.SyntaxProvider.ForAttributeWithMetadataName(
    "MyLib.GenerateMapperAttribute",
    predicate: static (node, _) => node is ClassDeclarationSyntax,     // syntax-only, very fast
    transform: static (ctx, ct) => BuildModel(ctx, ct));

ForAttributeWithMetadataName uses the compiler's attribute index and is dramatically faster than CreateSyntaxProvider scanning all nodes with semantic checks. Keep predicate purely syntactic; do semantic work in transform only for candidates.

3. Produce value-equatable models:

internal sealed record MapperModel(string Namespace, string Name, EquatableArray<PropertyModel> Properties);
internal sealed record PropertyModel(string Name, string Type);
  • Records give value equality, but ImmutableArray<T>/List<T> compare by reference β€” wrap collections in an EquatableArray<T> struct implementing sequence equality.
  • Never put ISymbol, SyntaxNode, SemanticModel, Location or Compilation into models: they're different objects every run (so equality always fails), and they keep entire compilations alive in memory.
  • If you need locations for diagnostics, store a lightweight equatable location (file path + text span).

4. Avoid CompilationProvider in the main path: Combine(context.CompilationProvider) changes on every edit, invalidating everything downstream. Extract only what you need (e.g. a single type's presence) via Select with a value result, so caching still works.

5. Split work by item: process per type/file with IncrementalValuesProvider, not one big Collect() of everything (a single change would regenerate all output). Use Collect() only when you truly need an aggregate (e.g. a registry file), and keep the collected model small and equatable.

6. Keep RegisterSourceOutput cheap and deterministic: string building only; same input β†’ same output text.

7. Respect cancellation: check CancellationToken in transforms; the IDE cancels runs frequently.

3. Keeping the IDE fast

  • Use RegisterImplementationSourceOutput for code that doesn't affect IDE semantics (e.g. method bodies that don't change the public shape), so the IDE can skip it during design-time builds.
  • Avoid heavy dependencies and reflection inside the generator.
  • Don't read files from disk directly; use AdditionalTextsProvider (cached and tracked).
  • Generate less code: share helpers via a runtime library or post-initialisation output instead of repeating them per type.
  • Measure: the IDE's "generator performance" diagnostics, dotnet build -bl with /p:ReportAnalyzer=true showing generator time, and Roslyn's ETW/EventSource events.

4. Testing incrementality

var driver = CSharpGeneratorDriver.Create([new MapperGenerator().AsSourceGenerator()],
    driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true));
driver = driver.RunGenerators(compilation);
var edited = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText("class Unrelated { }"));
driver = driver.RunGenerators(edited);

var result = driver.GetRunResult().Results[0];
Assert.All(result.TrackedSteps["BuildModel"].SelectMany(s => s.Outputs),
    o => Assert.True(o.Reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged));

Name steps with .WithTrackingName("BuildModel") so tests can inspect them.

5. A production example

After adding an in-house mapping generator, developers on a 300-project solution complained that typing lagged and Visual Studio's CPU spiked. The generator used CreateSyntaxProvider with semantic checks on every class, combined with CompilationProvider, and stored INamedTypeSymbol in its model. We rewrote it to use ForAttributeWithMetadataName, value-equatable models and per-type outputs, and added incrementality tests. Generator time per keystroke dropped from hundreds of milliseconds to near zero and the lag disappeared.

πŸ” What the interviewer is checking

The interviewer checks deep generator knowledge that separates working generators from good ones.

Green flags: pipeline and caching, value equality, no symbols in models, ForAttributeWithMetadataName, Collect trade-off, run reasons and tests, ReportAnalyzer timing.

Red flags: unaware of caching; puts symbols in models; uses CompilationProvider everywhere.

πŸ“š Learn in depth

1. Step caching semantics

Each transform node maintains a table of (input β†’ output) from the last run. Input changes are detected per item (e.g. per syntax tree). For changed inputs, the transform runs and its outputs are compared to previous outputs:

  • New β€” a new item,
  • Modified β€” recomputed and different,
  • Unchanged β€” recomputed but equal (downstream skipped),
  • Cached β€” not recomputed,
  • Removed.

2. Why symbols break caching

Symbols come from a specific Compilation instance. Every edit creates a new compilation, so symbols are new objects β†’ reference inequality β†’ Modified every time. Even if you implement custom comparers, holding symbols retains old compilations (huge memory).

3. EquatableArray pattern

public readonly struct EquatableArray<T>(ImmutableArray<T> array) : IEquatable<EquatableArray<T>>, IEnumerable<T> where T : IEquatable<T>
{
    public bool Equals(EquatableArray<T> other) => array.AsSpan().SequenceEqual(other.array.AsSpan());
    public override int GetHashCode() { var h = new HashCode(); foreach (var x in array) h.Add(x); return h.ToHashCode(); }
    ...
}

4. Syntax predicate guidelines

  • No semantic model access.
  • Quick checks (node kind, has attribute lists, name suffix).
  • Avoid allocations.

5. Design-time vs build-time

The IDE runs generators for design-time compilations (IntelliSense). RegisterImplementationSourceOutput outputs are skipped there, reducing IDE work. Build-time compilations include all outputs.

Try it yourself

  1. Write a generator with CreateSyntaxProvider and another with ForAttributeWithMetadataName; measure with ReportAnalyzer.
  2. Store a symbol in a model and observe incrementality tests failing.
  3. Implement EquatableArray<T> and fix it.
  4. Add WithTrackingName and inspect step reasons.

πŸ’» Code

using System;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

// Value-equal wrapper: ImmutableArray

⚠ Common mistakes

1. Non-incremental generators

What happens: using ISourceGenerator. Why it is wrong: full re-run on every change. Do instead: IIncrementalGenerator.

2. Semantic work in predicates

What happens: GetSemanticModel in syntax predicates. Why it is wrong: runs on every node; very slow. Do instead: syntactic predicates; semantic transforms only for candidates.

3. Symbols in models

What happens: models carry ISymbol/SyntaxNode. Why it is wrong: caching never hits; memory leaks. Do instead: value models of strings and primitives.

4. ImmutableArray<T> in records

What happens: assuming records compare collections by value. Why it is wrong: reference equality β†’ always modified. Do instead: EquatableArray<T>.

5. Combining with CompilationProvider

What happens: whole-compilation dependency. Why it is wrong: invalidates on every edit. Do instead: narrow providers.

6. One big Collect()

What happens: generating all output from one aggregated model. Why it is wrong: any change regenerates everything. Do instead: per-item outputs; small aggregates.

7. Ignoring cancellation

What happens: long transforms without checking tokens. Why it is wrong: wasted work and IDE lag. Do instead: check ct.ThrowIfCancellationRequested().

8. No incrementality tests

What happens: regressions in caching go unnoticed. Why it is wrong: IDE slowdowns return. Do instead: test step reasons in CI.

πŸ› Senior insight: trade-offs

Generator performance is IDE performance for every developer on the team.

Symptom Likely cause
Typing lag in large files Generator reruns on every keystroke
High IDE memory Symbols or compilations held in models
Slow builds in one project Expensive transform or Collect over many items
Output changes order between builds Non-deterministic iteration; sort before rendering

Seniors treat incrementality tests as part of the generator's contract and review generator code as carefully as runtime code.

πŸ”— Follow-ups the interviewer may ask

  1. When: Candidate refers to the generator's project setup Why: Checks authoring and packaging basics
    How to answer

    A source generator is a class marked [Generator] that implements IIncrementalGenerator (the older ISourceGenerator is deprecated). It runs inside the compiler, reads the user's code through Roslyn and adds new C# files to the compilation; it cannot modify existing code. The generator project targets netstandard2.0, references Microsoft.CodeAnalysis.CSharp (a version no newer than your consumers' compilers) with PrivateAssets="all", and sets EnforceExtendedAnalyzerRules. Consumers reference it as an analyser (OutputItemType="Analyzer" ReferenceOutputAssembly="false"), or you pack it into analyzers/dotnet/cs in a NuGet package. Use RegisterPostInitializationOutput for marker attributes and EmitCompilerGeneratedFiles to see the output.

  2. When: Candidate mentions compilations being recreated on each edit Why: Checks Roslyn's immutable compilation model
    How to answer

    Roslyn is the C# and VB compiler exposed as libraries. It parses source into immutable syntax trees (with full fidelity: every space and comment is kept as trivia), builds declarations and symbols, binds expressions to symbols and types, runs flow analysis, then lowers and emits IL, metadata and PDBs. The same objects are public: SyntaxTree, CSharpCompilation, SemanticModel, ISymbol and IOperation. IDEs, analysers, source generators and refactoring tools are all built on these APIs. You can use them to compile code at run time, write code-mod scripts that rewrite thousands of files, or build analysers that run in every build.

πŸ›  Real-world problems: how you handle them

IDE typing lag caused by a non-incremental generator
Situation
After adding an in-house mapping generator, developers in a [large solution] reported Visual Studio lag while typing.
Signals
Typing delays of [hundreds of milliseconds] in big files; IDE memory grew during the day.
Investigation
A binlog with `ReportAnalyzer=true` showed the generator's time; code review found `CreateSyntaxProvider` over all classes and `INamedTypeSymbol` in the model.
Resolution
Switched to `ForAttributeWithMetadataName`, extracted a record of strings with an equatable array, and generated one file per type.
Prevention
Added incrementality tests and a generator-time budget checked from binlogs in CI.

How to tell it (STAR)

Our [mapping generator] made typing lag across the team. I found it stored symbols in its model, so nothing cached. Moving to ForAttributeWithMetadataName and value-equal models fixed the lag, and I added tests that assert unrelated edits stay cached.

↩ Usually asked after

More in Compilation

Last updated 31 Oct 2026.

🌐 Sangam's Webring

βͺ Previous 🏠 Main Next ⏩
πŸ’¬ Chat with me