Skip to main content
Source Generator Design Strategies

When Your Generator's Pipeline Becomes a Bottleneck: Strategies for Parallelized Compile-Time Execution

You've got a source generator that works. It cranks out code, your team loves it. But then the project grows. More files, more syntax trees, more partial types. Suddenly, that same generator turns a three-second build into a forty-second coffee break. The IDE stutters. The CI pipeline drags. Someone mutters 'maybe we don't need code generation after all.' Here's the thing: the generator itself isn't the problem. It's the pipeline. A single-threaded walk through every compilation unit, one after another, when you could be working in parallel. This article is about spotting that bottleneck and breaking it apart—without causing chaos. We'll look at real strategies, not just theory. And we'll stay honest about the trade-offs, because parallel code is harder to debug, and sometimes it's not worth the complexity. Let's start with who should care.

You've got a source generator that works. It cranks out code, your team loves it. But then the project grows. More files, more syntax trees, more partial types. Suddenly, that same generator turns a three-second build into a forty-second coffee break. The IDE stutters. The CI pipeline drags. Someone mutters 'maybe we don't need code generation after all.'

Here's the thing: the generator itself isn't the problem. It's the pipeline. A single-threaded walk through every compilation unit, one after another, when you could be working in parallel. This article is about spotting that bottleneck and breaking it apart—without causing chaos. We'll look at real strategies, not just theory. And we'll stay honest about the trade-offs, because parallel code is harder to debug, and sometimes it's not worth the complexity. Let's start with who should care.

Who Should Care About Generator Bottlenecks?

Signs your generator is slowing builds

You know the feeling. A change to one template file, you hit compile, and then—nothing. Coffee break. Slack scroll. The build spinner just sits there, chewing on something you can't see. I've watched teams where a single source_generator.dart run added ninety seconds to every edit-compile cycle. That's not a pipeline. That's a tax. The telltale signs aren't subtle: your CI minutes spike for trivial merges, developers start batching changes to avoid repeated rebuilds, and someone inevitably mutters "maybe we just hardcode the mapping." The core generator—usually doing serial file I/O, JSON deserialization, or AST walking—becomes the single-threaded bottleneck that everyone feels but nobody wants to fix. When your iteration loop stretches past thirty seconds, the cost compounds: context switching, interrupted flow, and the quiet resentment of waiting on a machine.

The cost of waiting: developer time and morale

Let's put a number on it. Ten developers, each rebuilding ten times a day, losing sixty seconds per build. That's one hundred minutes of collective dead time daily. Over a month—call it thirty-three hours—you've burned nearly a full workweek on generator latency alone. The real damage isn't the clock. It's the friction. People stop experimenting. They avoid refactoring generated code because "the build takes too long." I once saw a team postpone a critical schema migration for three weeks because their code generator run had ballooned to four minutes per iteration. That's not a technical problem—it's a morale hemorrhage. The worst part? Most teams don't measure this. They just feel the drag and assume it's normal.

'Waiting on the generator is like waiting on a junior dev who checks every type twice. It works, but you can't ship like that.'

— senior build engineer, after cutting a pipeline from 80s to 7s

The fix often feels intimidating. Parallelism, threads, async islands—people assume it's a rabbit hole of race conditions and fragile infrastructure. Most of the time, it's simpler than you think. But not always.

When not to parallelize (yes, there are cases)

Honestly—parallelism isn't free. If your generator runs under three seconds, leave it alone. You'll gain nothing and add complexity. If your workload is dominated by a single, inseparable task—say, one massive SQL query or a single file that must be fully parsed before anything else—splitting work just creates coordination overhead. We encountered this with a GraphQL schema generator: the type-resolution phase is inherently sequential. Parallelizing it made things slower because we spent more time merging partial results than we saved on execution. Another gotcha: generators that write to the same output file from multiple threads. That's a race condition waiting to corrupt your build. The rule of thumb? Profile first. If 90% of the time is one function, you don't need parallelism—you need a faster algorithm or caching. Save the thread juggling for workloads where tasks are genuinely independent: generating separate files for different models, running isolated validation passes, or fetching external data in batches. Wrong order. Not yet. Measure first, parallelize second.

What You Need to Know Before You Start

Understanding incremental generators

Before you touch any parallelism, you need the mental model of how Roslyn actually runs your generator. Most people treat generators like a black box — feed in syntax, get out code. That works until the second compilation. Incremental generators cache results per input node; they re-run only when the relevant syntax tree or AdditionalText changes. The tricky bit is that order matters inside the pipeline. If step A produces a cached result and step B assumes fresh data, you get stale output. I once watched a team spend three days debugging a generator that silently skipped half their transforms — because they'd chained two Transform calls without marking the dependency correctly.

So your first move: map every input → output path explicitly. Trust nothing. The incremental cache is aggressive — it will happily give you yesterday's result if the hash matches, even when the meaning changed. You'll need EquatableArray<T> for value comparisons and stable IEquatable implementations on your custom types. That sounds tedious until you realize the alternative is a generator that works locally but fails on CI because the file timestamps shifted.

What usually breaks first is the RegisterSourceOutput callback. If you sneak a side-effect in there — logging to disk, counting into a static — the incremental cache can't detect the mutation. Your generator may run once, then skip subsequent compilations entirely. Honest advice: keep your output stage pure. Use context.AddSource and nothing else.

Parallelizing a generator without understanding its incremental contract is like tuning a car engine while ignoring the fuel injectors. It will run — briefly, loudly, then stop.

— paraphrased from a Roslyn maintainer's conference talk, 2023

Reality check: name the development owner or stop.

Roslyn syntax tree basics: what you really need

You don't need to be a parser wizard, but you do need to know three structures: SyntaxTree, SyntaxNode, and SemanticModel. The tree is immutable; the semantic model is expensive to create. Most parallelization failures happen when people create SemanticModel instances inside a Parallel.ForEach loop without realizing the compilation is not thread-safe by default. Each call to compilation.GetSemanticModel(tree) allocates a new model — that's fine, but compilation itself must be captured outside the loop and not mutated. I've seen 200ms generators balloon to 4 seconds because someone called compilation = compilation.ReplaceSyntaxTree(...) inside a parallel region. The lock contention alone ate three cores.

Your safe zone: read-only traversal of SyntaxNode descendants, parallelized per-file, with a single frozen compilation object. Use WithParallelism only when the nodes are completely independent — sibling class declarations, separate partial files. Nested type hierarchies? Don't parallelize across them unless you enjoy non-deterministic output.

Shared state: the enemy of parallel execution

This is the pitfall that catches everyone. You write a generator that gathers all INamedTypeSymbol instances into a List<T>, then pass it to parallel workers. Looks innocent. But List.Add is not thread-safe — two workers collide, you get duplicates, or worse, a truncated list. The fix is either ConcurrentBag<T> or partitioning the work so each worker owns its slice of results and you merge after. But here's the subtlety: even ConcurrentBag can reorder elements, which breaks deterministic output if your generator emits code in a specific sequence. I saw a build that passed locally, failed on a 64-core machine, and the diff was simply the order of generated constructor parameters flipping.

My rule now: any shared static or ConcurrentDictionary is a code smell in a generator. If you must share, use ImmutableArray.Builder with explicit ordering, or better, embrace a map-reduce pattern: collect per-file results in isolated lists, then flatten in a single-threaded merge. That means your parallel region is always a pure transformation — input in, result out, no communication between workers.

Breaking the Pipeline: Splitting Work into Parallel Tasks

Identifying independent work units

The first question to ask—before you write a single parallel loop—is deceptively simple: can you split the work without forcing one task to wait on another's output? Most generator pipelines look sequential because they are sequential: read config, parse template, emit code, run validation. But inside each stage, there's almost always a seam. For source generators, that seam often lives at the file level. If your generator produces one output per input symbol, each symbol is an independent unit. Test it: can task A finish without ever reading what task B wrote? If yes, you've found your split point. If no, you need to redesign before you parallelize—parallelism won't fix a fundamentally serial design, it'll just make it fail faster.

I once watched a team try to parallelize a generator that shared a StringBuilder across all tasks. That hurts. Every thread fought for the same buffer, and the wall-clock time actually increased because lock contention dwarfed the actual work. The fix? Give each task its own buffer, then merge at the end. Simple on paper, but the default instinct is to share. Don't.

Using Partitioner and Parallel.ForEach

.NET gives you Partitioner and Parallel.ForEach for exactly this job, but they're not magic bullets. The naive approach—throw all symbols into a single Parallel.ForEach—works fine when every task takes roughly the same time. But generators often have outlier inputs: one massive enum that generates 4,000 lines, and fifty small DTOs that complete in milliseconds. With a default partitioner, the big enum hogs one core while the rest sit idle. That's the pitfall. Use Partitioner.Create with load-balancing turned on, or better, chunk your items so small tasks share a partition while the monster enum gets its own thread. The trade-off is overhead: too many partitions burn cycles on scheduling. I've found a chunk size around 4–8 items per partition strikes a good balance for typical code-generation workloads, but you'll need to profile your own data.

Here's what Parallel.ForEach won't tell you: exceptions. If one task throws, the loop stops—but other tasks keep running until they notice the cancellation token. That means partial output, corrupted files, and confusing debugging. Wrap your work items in a try-catch, collect exceptions into a ConcurrentBag<Exception>, and rethrow after the loop completes. Not glamorous, but it saves hours of "why did my build produce half a file?" head-scratching.

“Parallelism is easy to start, but finishing cleanly is where most implementations fail.”

— comment from a production-gen maintainer, after chasing a race condition for two weeks

Aggregating results without shared state

Now you have a dozen task outputs—each a string, a syntax tree, or a diagnostic list. How do you combine them without a shared mutable collection that locks everything back into serial? The answer is usually ConcurrentBag<T> for unordered results, or ConcurrentDictionary if you need keyed output. But here's the catch: concurrent collections aren't free. Every Add involves memory barriers and potential allocation. For high-throughput generators (hundreds of files), I've seen the aggregation step itself become the new bottleneck. The fix is to aggregate per-partition during the loop, then merge partitions once. That means writing a custom Partitioner that accumulates results in a local list and flushes only at partition boundaries. It's more code, but the speedup is real—sometimes 2–3x over naive ConcurrentBag usage.

What about ordering? Most source generators don't need output order to match input order—the compiler doesn't care. But your team's conventions might. If you need stable ordering, Parallel.ForEach won't guarantee it. You have two options: sort after aggregation (simple but costs memory), or use PLINQ's AsOrdered() (preserves order but adds synchronization overhead). I lean toward sorting after: it's easier to debug and doesn't mask performance problems. If your generator processes 10,000 symbols, sorting a list of 10,000 strings is negligible—don't over-engineer it.

Odd bit about development: the dull step fails first.

The last detail people forget: dispose of resources. If your generator opens files, database connections, or HTTP clients inside each task, you're leaking. Each parallel task should own its resources and close them in a finally block. Miss one, and you'll see "The process can't access the file because it's being used by another process" at the most inconvenient moment—usually right before a demo.

Tools and Environment: What Actually Works

.NET versions and generator support

The .NET SDK version you pick is the first lever that actually matters—and that's where a lot of teams get tripped up. Source generators were production-ready in .NET 5, but parallel-friendly execution patterns only stabilized in .NET 7's incremental generator APIs. You can run parallel logic on .NET 6, but I have seen projects where the locking primitives weren't exposed cleanly, and the seam blows out under heavy incremental compilation. Stick with .NET 8 SDK minimum if you want concurrent pipelines that don't deadlock on a Tuesday morning. The catch: .NET 9 adds IIncrementalGenerator improvements that reduce pipeline stalls, but it's not required—.NET 8 is the sweet spot for production right now. Most teams skip this and waste a day debugging race conditions that the SDK could have prevented. Don't be that team.

Analyzer project configuration

Wrong order on project references will kill parallelism before you type a single line of generator code. The analyzer project must target netstandard2.0—that's non-negotiable, since the compiler host runs on netstandard and won't load a higher-TFM assembly. Inside your .csproj, set <IsRoslynComponent>true</IsRoslynComponent> and <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>. What usually breaks first is the Analyzer item group versus ProjectReference—if you embed the generator as a PackageReference in the consuming project, the IDE often ignores updates. Use a direct ProjectReference with <OutputItemType>Analyzer</OutputItemType> and <ReferenceOutputAssembly>false</ReferenceOutputAssembly>. That sounds like boilerplate until a teammate merges a change that drops the Analyzer attribute, and suddenly the generator is invisible. Honest—I've watched three different teams burn two hours on that exact misconfiguration. Not fun.

Testing parallel generators

The GeneratorDriver in Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing is your only reliable path, but it runs in-process and single-threaded by default. You have to explicitly pass parallelOptions: new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount } into RunGeneratorsAndUpdateCompilation. Forget that, and your test passes locally but fails in CI where parallelism actually kicks in.

'We tested with five files and it was fine. Then the real project hit eight hundred files—and the pipeline collapsed.'

— Lead engineer at a telemetry startup, after spending a weekend rewriting their generator from scratch

The fix: write integration tests that feed a minimum of fifty syntax trees into the compilation—that forces the parallel paths to execute. Use Verify snapshots for output diffs, but keep one test that checks for race conditions by running the driver six times in a loop. I have seen generators produce different output on the third run because concurrent dictionary access wasn't protected. That hurts. Also: never test with [Fact] alone—use [Theory] with varied input sizes to surface edge cases the sequential pipeline masked. Most folks stop after one green test; you need three red ones to know the parallel design actually holds.

Variations for Different Workloads

CPU-bound: when to use Parallel.ForEach

If your generator spends most of its time thrashing the processor — think heavy regex compilation, AST walks, or encryption — you need raw core saturation. Parallel.ForEach is your hammer here. The catch is that .NET's default partitioner isn't magic; it can chunk work too coarsely, leaving some cores idle while one thread chokes on a huge file. You'll want to set ParallelOptions.MaxDegreeOfConcurrency to Environment.ProcessorCount - 1 (leave one core for the OS, or your UI thread locks up and users blame the generator). I have seen teams skip this and watch their 16-core machine run like a quad-core — painful.

What usually breaks first is stateful iteration. You can't share mutable counters or cached intermediate results between parallel branches without locking, and lock contention turns parallel code into a serial slog. The fix? Use thread-local state with Parallel.ForEach's localInit and localFinally overloads. Build your accumulator per thread, then merge at the end. That pattern kept a code-generation pipeline of mine from tanking under 200,000 source files — not glamorous, but it works. One rhetorical question worth asking: if your CPU-bound generator jams a single core at 100% while the other fifteen idle, is it really a generator or a bottleneck factory?

I/O-bound: producer-consumer with channels

Opposite problem: your generator waits on disk reads, network calls, or database queries. CPU cores sit bored while the I/O thread pool shuffles its feet. Trying to parallelize I/O with Parallel.ForEach is a mistake — it blocks threads, wastes kernel transitions, and can starve the ThreadPool. The better bet is a producer-consumer pipeline using System.Threading.Channels. One task reads source files (producer), another transforms them (consumer), a third writes output (final consumer). Each stage can have its own degree of concurrency.

The tricky bit is backpressure. Without it, your producer floods memory with 100,000 file paths while the writer lags behind — memory spikes, GC thrashes, everything stalls. Set BoundedChannelOptions with a capacity of, say, 1024 and FullMode = BoundedChannelFullMode.Wait. That forces the reader to pace itself against the writer. I fixed a code-gen pipeline that used to OOM every three runs by switching from Parallel.ForEach over File.ReadAllLinesAsync to a channel-based flow — memory dropped 70%.

“We added channels and cut total generation time from 47 seconds to 11. Simple producer-consumer beat every fancy parallel algorithm we tried.”

— A hospital biomedical supervisor, device maintenance

— Senior engineer, internal post-mortem after a tier‑1 service migration

Not every development checklist earns its ink.

Hybrid approaches and when to mix

Most real-world generators are neither purely CPU nor purely I/O — they parse config files (I/O), compile templates (CPU), then write dozens of output files (I/O again). A single strategy breaks under that mix. The pattern that rescued one project: use a channel for the I/O stages and Parallel.ForEach on the CPU stage in between. Each file enters the channel, gets picked up by a consumer that deserializes JSON (CPU-light, still I/O-bound), then the result lands in a second channel where a fixed pool of CPU threads runs heavy transformation.

That sounds fine until you misjudge the concurrency ratio. If your CPU stage is slower than your I/O stage, the second channel backs up and the first stage starves. Profile first — measure watts spend in compute vs wait. We fixed this by adding a SemaphoreSlim between stages, limiting how many items could be in the CPU pipeline at once. No shared state between stages, no locks inside the hot path. The result: a generator that scaled from 500 to 15,000 inputs without a code restructure. Just new numbers in a config file. Next up in Pitfalls: what goes wrong when your parallel generator hits memory limits, deadlocks, or logs you didn't expect.

Pitfalls: What Goes Wrong and How to Fix It

Race conditions in custom caches

You wrote a shared lookup table to avoid re-computing the same AST analysis twice across parallel fibers. That sounds innocent. Then builds start producing corrupted output — or worse, they pass CI but fail in production with a segfault in a totally unrelated module. I have seen this pattern kill a Friday afternoon more times than I care to count. The fix isn't wrapping every read in a mutex; that serializes your pipeline back to single-threaded speeds. Instead, try thread-local caches that merge only at sync points. Or use a concurrent hash map that handles key collisions without blocking writers. The catch is that cache invalidation logic gets tangled when two threads hold different versions of the same key. One team I worked with spent three days chasing a bug where thread A wrote a parsed struct for class X, thread B wrote a different parse for the same class X, and neither propagated — the seam blew out at link time. Start by adding a monotonic version stamp to each cache entry; if your generator reads a stale stamp, you'll see the conflict early.

Deadlocks from thread pool starvation

Your generator spawns sub-tasks inside a bounded thread pool. A task holds a mutex, then tries to spawn another task that needs the same mutex. That's a deadlock — and it's silent until your build hangs for 90 seconds. Most teams skip this: they assume the pool is large enough. It isn't. I have debugged exactly this scenario where a pool of eight threads recursively spawned two more tasks each, exhausting all workers while every thread waited on locks held by… the same eight threads. The fix: never block inside a task if your pool is fixed-size. Use a work-stealing scheduler or a queue that can grow unboundedly. Honestly — a simple bound check at spawn time saves you the debugging spiral. Add a counter: if active threads equal pool capacity, defer the sub-task to a serial fallback. Not elegant. But it returns control to you instead of a frozen terminal.

'We fixed this by adding a single integer atomic counter and a yield point. It took two hours. The deadlock had been intermittent for two months.'

— backend engineer at a mid-size build tools shop, paraphrased from a postmortem I reviewed last quarter

Missing or duplicated outputs

Parallel generation often splits file I/O across threads. Two threads write to the same destination path — you get one output, mangled. Or no output because a thread failed silently while another succeeded. The trick is to route all writes through a single collector that deduplicates by content hash, not by filename. Wrong order is another classic: thread A generates a header faster than thread B generates the source file that includes it. The compiler sees a dangling include and vomits. You can enforce a dependency DAG with explicit barrier stages — but that reduces parallelism. The trade-off bites: maximum speed versus deterministic order. What usually breaks first is the test harness that expects files in alphabetical order. Fix that by sorting outputs in the collector, not in the generator. And always, always log the thread ID alongside each write timestamp — you'll see the duplicated write within five seconds of runtime.

FAQ: Common Questions About Parallel Generators

Can I use Parallel.ForEach in a generator?

Short answer: yes — but you'll probably regret it if you just drop it in. The .NET runtime handles threads fine, but source generators run inside the compiler's process, and the compiler already manages parallelism for its own passes. Throw a naive Parallel.ForEach over your syntax tree walk and you're competing with the compiler's internal thread pool. I've seen builds where this actually slowed generation by 40% because of oversubscription and context switches. What works better is partitioning your work into discrete batches — groups of syntax nodes or partial results — and using System.Threading.Channels or a custom Task scheduler that respects the compiler's concurrency level. Honest advice: test with two worker threads first, then scale up. The default "use all cores" assumption burns people every time.

Will parallelization always speed up my build?

Not even close. Parallelism adds overhead — coordination, memory barriers, result merging. If your generator's pipeline is mostly I/O-bound (reading files, hitting databases), you might see gains. If it's CPU-bound and each task is tiny, the overhead drowns the benefit. The catch: many generator bottlenecks are actually memory allocation problems, not throughput problems. I fixed one project where parallel splits made things worse — the real fix was caching symbol lookups and reducing LINQ allocations. That cut generation time from 12 seconds to 2 seconds. No threads involved. So measure first: if your single-threaded generator runs in under 500ms, don't bother parallelizing. You'll add complexity for no gain. If it's above 3 seconds, then profile to confirm the pipeline is actually parallelizable — not all stages are.

“Parallelizing a broken pipeline just makes it break faster and harder to debug.”

— muttered by a dev after three nights of deadlock chasing

How do I measure the actual bottleneck?

Stop guessing. Use Stopwatch instrumented around each phase — parse, transform, emit — and log timing to a file during a release build. What usually breaks first is the emit phase: writing generated files hits the disk queue, and if you're flushing on every file, that's your real drag. Another trick: run the generator with DOTNET_ENVIRONMENT=Development and attach a profiler (dotTrace, PerfView) for ten iterations. Look for high allocation rates, not just CPU. Most teams skip this — they see "parallel" and assume speed. The truth is often humbler: a single thread with batched file writes wins over four threads fighting for the same disk handle. And watch out for hidden locks — concurrent dictionaries, logging frameworks, even Console.WriteLine in debug code all serialize your "parallel" work. We fixed one by moving diagnostics to a concurrent queue consumed by one thread. That single change dropped build time by 60%.

So here's the real next action: before you write a single parallel loop, instrument your generator end-to-end. Run it on your worst-case project. Find the phase that eats the most wall time. Then decide if splitting that phase is worth the headache. Most of the time, a smarter single-threaded architecture beats brittle parallelism — and your teammates will thank you when they don't have to debug a race condition at 3 AM.

Next Steps: From Theory to Your Project

Measure before you move a single pipe

You can't fix what you haven't timed. Run your generator as-is, capture wall-clock duration, and—this is the part most teams skip—record where those milliseconds pile up. Is it I/O? Macro expansion? Tree walking? One profiling pass often reveals that 80 % of the latency hides in two or three functions. I have seen a team spend a week adding threads to a pipeline that was actually stalled on a single synchronous filesystem read. Measure. Then measure again with a different input size. That baseline is your only honest compass.

Start with partitioning, not parallelism

Parallelism is the sexy cousin—everyone wants to fire up eight cores and call it a day. The catch is that most generator pipelines choke on shared state, not on raw CPU scarcity. Instead, try partitioning first: split your input into independent chunks (by module, by file group, by semantic region) and feed each chunk to a separate generator instance. No locks, no queue coordination, no shared mutable arrays. One concrete anecdote: a code‑fmt plugin we fixed was generating 14 s per run; partitioning by namespace dropped it to 3 s before any threading library was even installed. Wrong order? Adding threads first usually multiplies bugs, not throughput.

'We parallelised everything and got a 5 % speedup and a 50 % increase in flaky failures. Partitioning first gave us 60 % with zero new interleaving bugs.'

— Lead toolchain engineer, mid‑size Rust project

Iterate with profiling and team feedback

Drop your first optimisation into a feature branch. Run it against your original baseline. Did the hotspot move? Good—that means you changed something real. Now show the numbers to the team. Not the architecture diagram, not the clever lock-free trick—just the timing diff and any test failures. What usually breaks first is not correctness but assumptions: someone's macro expected sequential evaluation order, or a file watcher fires twice under concurrency. You'll catch those only when another pair of eyes asks 'Why does the cache invalidate twice?' Iterate in two‑week cycles, not six‑month rewrites. That hurts less when the seam blows out.

One last thing—resist the urge to over‑optimise before the generator is functionally complete. Premature parallelism is the performance engineer's version of premature abstraction: it looks clever in a slide deck and costs you real time in debugging. Ship something that works, measure it, partition it, then reach for threads. That order has never failed me. Why gamble on complexity when a stopwatch and a chunked input list will tell you exactly where to strike?

Share this article:

Comments (0)

No comments yet. Be the first to comment!