Blog

beskar-codegen

Writing a Zero-Allocation Source Generator: Roslyn, Cache-Safe Nodes, and Ref Struct Builders

A deep-dive guide to writing high-performance Roslyn Incremental Source Generators using cache-friendly syntax nodes, SequenceArray for zero-boilerplate pipeline caching, and CodeTextWriter.

July 7, 2026 7 min read Marvin Drude
C#.NETSource GeneratorsRoslynPerformanceMemory

The compiler is a hot path

Source generators are often treated as developer conveniences: you write a clean declaration, and the generator writes the boring boilerplate or to prevent runtime allocations and cpu overhead.

But when you write a source generator, your code is no longer just running in production. It is running inside the developer's IDE (Rider or Visual Studio). It runs as they type.

If your generator does not respect memory:

  1. Garbage collection pauses in the IDE: Allocating millions of intermediate strings and arrays forces the IDE's heap to bloat, causing typing lag.
  2. Cache invalidation loops: If your incremental generator outputs unstable nodes, the Roslyn compiler will rebuild the source generator output on every keystroke, forcing full compilation cycles.
  3. OutOfMemory exceptions: In large solutions with thousands of files, inefficient generators can consume several gigabytes of compiler memory over time.

To write a source generator that runs fast and clean, we need to solve two distinct problems:

  • Avoid running the generator logic when code edits do not affect the output shape (Cache Safety).
  • Avoid allocating memory when we do run the generator logic (Zero-Allocation Builders).

Part 1: Caching the incremental pipeline with SequenceArray<T>

Roslyn introduced the IIncrementalGenerator API to replace the older, slower ISourceGenerator. The idea is straightforward: construct a pipeline of transformations that filters and maps syntax nodes before producing source code.

But a pipeline is only as fast as its caching. If any stage of your pipeline returns a model that is not structurally equal to the previous run, everything downstream is invalidated and executed.

text
[Syntax Tree] -> [Filter / Transform] -> [Model Extraction] -> [Equality Check] -> [Code Generation]
                                                                      |
                                                            (If Equal, Stop Here)

Here is a common mistake developers make when defining their data extraction model:

C#
// Naive model containing collection lists
public record GenerationModel(
    string Namespace,
    string ClassName,
    IReadOnlyList<string> PropertyNames // DANGER: Default record Equals uses ReferenceEquality for lists!
);

The Collection Equality Trap

By default, C# record equality is generated automatically. It performs value comparison on primitive fields, but for collections like IReadOnlyList<T> or arrays, it falls back to Reference Equality.

If a generator runs twice and extracts the exact same properties, the compiler will see two different list references. The cache check fails, invalidating the pipeline stage, and forces the code emitter to run anyway.

Normally, the only way out is writing a custom Equals and GetHashCode implementation by hand to compare list contents. This is boring, repetitive, and prone to bugs when new fields are added.

The Solution: SequenceArray<T>

In Beskar.Memory, we have a dedicated collection type: SequenceArray<T>.

SequenceArray<T> is a read-only wrapper around a contiguous memory block that implements structural value equality based on its contents rather than its reference.

By using SequenceArray<T>, the compiler-generated Equals and GetHashCode for your records automatically compare the sequence items. No manual boiler plates, no custom comparers.

C#
using Beskar.Memory;

namespace Beskar.CodeGeneration.Engine;

// Clean, compiler-generated structural equality out of the box!
public sealed record GenerationModel(
   string Namespace,
   string ClassName,
   SequenceArray<string> PropertyNames
);

Now register this model inside the Initialize method of the incremental generator:

C#
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Beskar.Memory;

[Generator]
public sealed class HighPerformanceGenerator : IIncrementalGenerator
{
   public void Initialize(IncrementalGeneratorInitializationContext context)
   {
      // 1. Filter syntax to find target classes
      var provider = context.SyntaxProvider
         .CreateSyntaxProvider(
            predicate: static (node, _) => node is ClassDeclarationSyntax,
            transform: static (ctx, _) => ExtractModel(ctx))
         .Where(static model => model is not null);

      // 2. Register the pipeline (Roslyn uses GenerationModel.Equals for caching)
      context.RegisterSourceOutput(provider!, static (spc, model) =>
      {
         GenerateCode(spc, model);
      });
   }

   private static GenerationModel? ExtractModel(GeneratorSyntaxContext context)
   {
      var classDecl = (ClassDeclarationSyntax)context.Node;
      var symbol = context.SemanticModel.GetDeclaredSymbol(classDecl);
      if (symbol is null) return null;

      var ns = symbol.ContainingNamespace?.ToDisplayString() ?? "Global";
      var name = symbol.Name;
      
      var properties = new List<string>();
      foreach (var member in symbol.GetMembers())
      {
         if (member is IPropertySymbol prop)
         {
            properties.Add(prop.Name);
         }
      }

      // Convert our list to a SequenceArray to guarantee structural equality
      return new GenerationModel(ns, name, new SequenceArray<string>(properties));
   }
}

Because GenerationModel uses SequenceArray<string>, changes to other files or local method bodies in the same class will never trigger the generation phase. The pipeline stops at the cache check.


Part 2: Zero-allocation writing with CodeTextWriter

When the cache check does pass, we must emit the generated code. The standard approach to generating source code looks like this:

C#
// Eager allocation pattern
var sb = new StringBuilder();
sb.AppendLine($"namespace {model.Namespace}");
sb.AppendLine("{");
sb.AppendLine($"    public partial class {model.ClassName}");
sb.AppendLine("    {");
// Loop, interpolate, append...

To solve this, Beskar.Memory.Code provides CodeTextWriter. CodeTextWriter is a high-performance stack-only ref struct writer that formats indent-aware code.

By writing directly to rented buffers, it avoids intermediate string segments entirely. Here is the conceptual shape of the writer:

C#
namespace Beskar.Memory.Code;

public ref struct CodeTextWriter
{
   private readonly SpanOwner<char> _output;
   private int _indent;

   public CodeTextWriter(SpanOwner<byte> output)
   {
      _output = output;
      _indent = 0;
   }

   public void UpIndent() => _indent++;
   public void DownIndent() => _indent = Math.Max(0, _indent - 1);
   
   // Write methods take ReadOnlySpan<char> and encode UTF8 bytes directly
   public void Write(ReadOnlySpan<char> text);
   public void WriteLine(ReadOnlySpan<char> text);
   public void WriteLine();
}

Part 3: Chaining writers with C# 14 extension blocks

In C# 14 extension members, I explained how extension blocks let you write fluent helper APIs for stack-only structures. We can apply that pattern here to make CodeTextWriter just as clean to use as a traditional StringBuilder.

Let's group related generation helpers in an extension block:

C#
using System;
using Beskar.Memory.Code;

namespace Beskar.CodeGeneration.Writers;

public static class CodeTextWriterExtensions
{
   extension(ref CodeTextWriter writer)
   {
      public void WriteAutoGeneratedHeader()
      {
         writer.WriteLine("// <auto-generated />");
         writer.WriteLine("// This file was generated by Beskar.CodeGeneration.");
         writer.WriteLine("// Do not edit this file manually.");
         writer.WriteLine();
      }

      public void OpenNamespace(ReadOnlySpan<char> ns)
      {
         writer.Write("namespace ");
         writer.Write(ns);
         writer.WriteLine();
         writer.WriteLine("{");
         writer.UpIndent();
      }

      public void CloseNamespace()
      {
         writer.DownIndent();
         writer.WriteLine("}");
      }
   }
}

Because the extension methods accept the writer as a ref parameter we could even make this API a more fluent one: instead of returning void, we could return the writer ref itself.


Part 4: Putting it all together (Real-world Usage)

In a real-world generator like the EnumGenerator in Beskar.Memory, code generation is structured by subclassing a base CodeRenderer. The renderer is instantiated with the metadata spec and handles the lifecycle of the CodeTextWriter.

Here is how you implement a dedicated code renderer:

C#
using System;
using Beskar.Memory.Code;
using Beskar.Memory.Code.Rendering;
using Microsoft.CodeAnalysis;

namespace Beskar.CodeGeneration.Engine.Rendering;

public sealed class EnumExtensionRenderer(SourceProductionContext ctx)
   : CodeRenderer(ctx)
{
   public required FastEnumSpec Spec { get; init; }

   protected override string Render()
   {
      // Initialize with stack-allocated buffers for zero allocations
      // Falls back to rented arrays
      var writer = new CodeTextWriter(
         stackalloc char[512], stackalloc char[128]);

      try
      {
         writer.WriteEnableNullable(true);
         writer.WriteAutoGeneratedOpen();
         writer.WriteLineInterpolated($"// This file is auto-generated by EnumGenerator.");
         writer.WriteAutoGeneratedClose();

         writer.WriteUsing("System");
         writer.WriteLine();

         var spec = Spec.NamedType;
         writer.WriteNamespace(spec.Symbol.NameSpace);

         writer.WriteLineInterpolated($"{spec.Symbol.Accessibility.ToKeywordString()} static partial class {spec.Symbol.Name}Extensions");
         writer.OpenBody();

         // C# 14 extension blocks integration
         writer.WriteLineInterpolated($"extension({spec.Symbol.FullName} val)");
         writer.OpenBody();
         
         // Custom generation logic for enum extensions
         // ...
         
         writer.CloseBody(); // close extension block
         writer.CloseBody(); // close class body
         
         return writer.ToString();
      }
      finally
      {
         // Release any internally rented arrays if the writer grew past stackalloc limits
         writer.Dispose();
      }
   }
}

Zero-Allocation String Interpolation

You will notice the use of writer.WriteLineInterpolated($"...") instead of standard writer.WriteLine($"..."). Under the hood, CodeTextWriter leverages custom C# Interpolated String Handlers.

Instead of formatting strings on the heap before passing them to the writer, the compiler translates this call into direct writes of each segment of the interpolated string into the underlying buffer.

Instantiating the Renderer in the Pipeline

Once the renderer is defined, triggering it from your incremental generator's production loop is straightforward:

C#
private static void GenerateCode(
   SourceProductionContext context, 
   GenerationModel model)
{
   // Map your GenerationModel to the required spec
   var spec = new FastEnumSpec { NamedType = model.NamedType };

   // Instantiate and execute the renderer
   var renderer = new EnumExtensionRenderer(context)
   {
      Spec = spec
   };

   // Execute writes the string returned by Render() directly into context.AddSource
   renderer.Execute($"{model.ClassName}Extensions.g.cs");
}

The payoff

By building your source generator pipeline with this architecture:

  1. The extraction phase only triggers when actual code shape changes. By using SequenceArray<T> from Beskar.Memory, you get robust cache safety using auto-generated records without writing a single line of custom comparison code.
  2. The generation phase relies on a pooled buffer, CodeTextWriter from Beskar.Memory.Code, and direct UTF-8 span encoding, producing zero heap allocations inside the compilation hot path.
  3. The code design remains clean, expressive, and fluent, utilizing C# 14 extension blocks on ref structs to hide buffer complexity.

Source generators are part of your compiler tooling. By designing them with the same memory budgets you apply to high-performance database and socket code, you keep compile times fast, IDEs responsive, and your development cycle smooth.

Keep your compilation cycles fast and your builders low-allocated!

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.