diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
new file mode 100644
index 0000000..a66fb43
--- /dev/null
+++ b/.github/workflows/benchmarks.yml
@@ -0,0 +1,79 @@
+name: Benchmarks
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ benchmark:
+ name: Run Benchmarks
+ runs-on: ubuntu-latest
+
+ env:
+ CI: true
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Cache NuGet packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.nuget/packages
+ key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Packages.props') }}
+ restore-keys: |
+ nuget-${{ runner.os }}-
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore -c Release
+
+ - name: Run benchmarks
+ run: >-
+ dotnet run -c Release
+ --project benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj
+ --
+ --filter "*"
+ --exporters json
+ --join
+ --job short
+ --iterationCount 3
+ --warmupCount 1
+
+ - name: Find benchmark result
+ id: find-result
+ run: |
+ file=$(find BenchmarkDotNet.Artifacts -name '*-report-full-compressed.json' | head -1)
+ if [ -z "$file" ]; then
+ echo "No benchmark result file found" >&2
+ exit 1
+ fi
+ echo "file=$file" >> "$GITHUB_OUTPUT"
+
+ - name: Store benchmark result
+ uses: benchmark-action/github-action-benchmark@v1
+ with:
+ name: ExpressiveSharp Benchmarks
+ tool: benchmarkdotnet
+ output-file-path: ${{ steps.find-result.outputs.file }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ auto-push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
+ comment-on-alert: true
+ alert-threshold: '120%'
+ fail-on-alert: false
+ gh-pages-branch: gh-pages
+ benchmark-data-dir-path: dev/bench
diff --git a/CLAUDE.md b/CLAUDE.md
index 78d7779..42f8c01 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,6 +27,12 @@ VERIFY_AUTO_APPROVE=true dotnet test tests/ExpressiveSharp.Generator.Tests
# Pack NuGet packages locally
dotnet pack -c Release
+
+# Run benchmarks (all)
+dotnet run -c Release --project benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj -- --filter "*"
+
+# Run benchmarks (specific class)
+dotnet run -c Release --project benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj -- --filter "*GeneratorBenchmarks*"
```
CI targets both .NET 8.0 and .NET 10.0 SDKs.
@@ -83,6 +89,7 @@ ExpressiveSharp.EntityFrameworkCore.CodeFixers (Roslyn analyzer, netstandard2.0)
| `ExpressiveSharp.IntegrationTests.ExpressionCompile` | Compiles and invokes generated expression trees directly |
| `ExpressiveSharp.IntegrationTests.EntityFrameworkCore` | EF Core query translation validation |
| `ExpressiveSharp.EntityFrameworkCore.Tests` | EF Core integration-specific tests |
+| `ExpressiveSharp.Benchmarks` | BenchmarkDotNet performance benchmarks (generator, resolver, replacer, transformers, EF Core) |
### Three Verification Levels (see `docs/testing-strategy.md`)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ab6c656..d2196b4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,5 +12,6 @@
+
diff --git a/ExpressiveSharp.slnx b/ExpressiveSharp.slnx
index c007206..677ed84 100644
--- a/ExpressiveSharp.slnx
+++ b/ExpressiveSharp.slnx
@@ -1,4 +1,7 @@
+
+
+
diff --git a/benchmarks/Directory.Build.props b/benchmarks/Directory.Build.props
new file mode 100644
index 0000000..66c7fc4
--- /dev/null
+++ b/benchmarks/Directory.Build.props
@@ -0,0 +1,7 @@
+
+
+
+
+ net10.0
+
+
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/EFCoreQueryOverheadBenchmarks.cs b/benchmarks/ExpressiveSharp.Benchmarks/EFCoreQueryOverheadBenchmarks.cs
new file mode 100644
index 0000000..2016aae
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/EFCoreQueryOverheadBenchmarks.cs
@@ -0,0 +1,56 @@
+using BenchmarkDotNet.Attributes;
+using ExpressiveSharp.Benchmarks.Helpers;
+using Microsoft.EntityFrameworkCore;
+
+namespace ExpressiveSharp.Benchmarks;
+
+[MemoryDiagnoser]
+public class EFCoreQueryOverheadBenchmarks
+{
+ private TestDbContext _baselineCtx = null!;
+ private TestDbContext _expressiveCtx = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _baselineCtx = new TestDbContext(useExpressives: false);
+ _expressiveCtx = new TestDbContext(useExpressives: true);
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ _baselineCtx.Dispose();
+ _expressiveCtx.Dispose();
+ }
+
+ [Benchmark(Baseline = true)]
+ public string Baseline()
+ => _baselineCtx.Entities.Select(x => x.Id).ToQueryString();
+
+ [Benchmark]
+ public string WithExpressives_Property()
+ => _expressiveCtx.Entities.Select(x => x.IdPlus1).ToQueryString();
+
+ [Benchmark]
+ public string WithExpressives_Method()
+ => _expressiveCtx.Entities.Select(x => x.IdPlus1Method()).ToQueryString();
+
+ [Benchmark]
+ public string WithExpressives_NullConditional()
+ => _expressiveCtx.Entities.Select(x => x.EmailLength).ToQueryString();
+
+ [Benchmark]
+ public string ColdStart_WithExpressives()
+ {
+ using var ctx = new TestDbContext(useExpressives: true);
+ return ctx.Entities.Select(x => x.IdPlus1).ToQueryString();
+ }
+
+ [Benchmark]
+ public string ColdStart_Baseline()
+ {
+ using var ctx = new TestDbContext(useExpressives: false);
+ return ctx.Entities.Select(x => x.Id).ToQueryString();
+ }
+}
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/ExpressionReplacerBenchmarks.cs b/benchmarks/ExpressiveSharp.Benchmarks/ExpressionReplacerBenchmarks.cs
new file mode 100644
index 0000000..d136304
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/ExpressionReplacerBenchmarks.cs
@@ -0,0 +1,59 @@
+using System.Linq.Expressions;
+using BenchmarkDotNet.Attributes;
+using ExpressiveSharp.Benchmarks.Helpers;
+using ExpressiveSharp.Services;
+
+namespace ExpressiveSharp.Benchmarks;
+
+[MemoryDiagnoser]
+public class ExpressionReplacerBenchmarks
+{
+ private Expression _propertyExpression = null!;
+ private Expression _methodExpression = null!;
+ private Expression _nullConditionalExpression = null!;
+ private Expression _blockBodyExpression = null!;
+ private Expression _deepChainExpression = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ // Build expression trees that reference [Expressive] members on TestEntity.
+ // When Replace is called, the replacer will resolve and inline them.
+ Expression> propExpr = e => e.IdPlus1;
+ _propertyExpression = propExpr;
+
+ Expression> methodExpr = e => e.IdPlus1Method();
+ _methodExpression = methodExpr;
+
+ Expression> nullCondExpr = e => e.EmailLength;
+ _nullConditionalExpression = nullCondExpr;
+
+ Expression> blockExpr = e => e.GetCategory();
+ _blockBodyExpression = blockExpr;
+
+ // Deep chain: multiple [Expressive] member accesses in one tree
+ Expression> deepExpr = e =>
+ $"{e.FullName} ({e.GetCategory()}) #{e.IdPlus1Method()}";
+ _deepChainExpression = deepExpr;
+ }
+
+ [Benchmark(Baseline = true)]
+ public Expression Replace_Property()
+ => new ExpressiveReplacer(new ExpressiveResolver()).Replace(_propertyExpression);
+
+ [Benchmark]
+ public Expression Replace_Method()
+ => new ExpressiveReplacer(new ExpressiveResolver()).Replace(_methodExpression);
+
+ [Benchmark]
+ public Expression Replace_NullConditional()
+ => new ExpressiveReplacer(new ExpressiveResolver()).Replace(_nullConditionalExpression);
+
+ [Benchmark]
+ public Expression Replace_BlockBody()
+ => new ExpressiveReplacer(new ExpressiveResolver()).Replace(_blockBodyExpression);
+
+ [Benchmark]
+ public Expression Replace_DeepChain()
+ => new ExpressiveReplacer(new ExpressiveResolver()).Replace(_deepChainExpression);
+}
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/ExpressionResolverBenchmarks.cs b/benchmarks/ExpressiveSharp.Benchmarks/ExpressionResolverBenchmarks.cs
new file mode 100644
index 0000000..7c381be
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/ExpressionResolverBenchmarks.cs
@@ -0,0 +1,66 @@
+using System.Reflection;
+using BenchmarkDotNet.Attributes;
+using ExpressiveSharp.Benchmarks.Helpers;
+using ExpressiveSharp.Services;
+
+namespace ExpressiveSharp.Benchmarks;
+
+[MemoryDiagnoser]
+public class ExpressionResolverBenchmarks
+{
+ private ExpressiveResolver _resolver = null!;
+ private MemberInfo _propertyMember = null!;
+ private MemberInfo _methodMember = null!;
+ private MemberInfo _methodWithParamsMember = null!;
+ private MemberInfo _constructorMember = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _resolver = new ExpressiveResolver();
+
+ var type = typeof(TestEntity);
+ _propertyMember = type.GetProperty(nameof(TestEntity.IdPlus1))!;
+ _methodMember = type.GetMethod(nameof(TestEntity.IdPlus1Method))!;
+ _methodWithParamsMember = type.GetMethod(nameof(TestEntity.IdPlusDelta))!;
+ _constructorMember = type.GetConstructor(new[] { typeof(TestEntity) })!;
+
+ // Warm up the caches so we measure steady-state
+ _resolver.FindGeneratedExpression(_propertyMember);
+ _resolver.FindGeneratedExpression(_methodMember);
+ _resolver.FindGeneratedExpression(_methodWithParamsMember);
+ _resolver.FindGeneratedExpression(_constructorMember);
+ }
+
+ [Benchmark(Baseline = true)]
+ public object Resolve_Property()
+ => _resolver.FindGeneratedExpression(_propertyMember);
+
+ [Benchmark]
+ public object Resolve_Method()
+ => _resolver.FindGeneratedExpression(_methodMember);
+
+ [Benchmark]
+ public object Resolve_MethodWithParams()
+ => _resolver.FindGeneratedExpression(_methodWithParamsMember);
+
+ [Benchmark]
+ public object Resolve_Constructor()
+ => _resolver.FindGeneratedExpression(_constructorMember);
+
+ [Benchmark]
+ public object? ResolveViaReflection_Property()
+ => ExpressiveResolver.FindGeneratedExpressionViaReflection(_propertyMember);
+
+ [Benchmark]
+ public object? ResolveViaReflection_Method()
+ => ExpressiveResolver.FindGeneratedExpressionViaReflection(_methodMember);
+
+ [Benchmark]
+ public object? ResolveViaReflection_MethodWithParams()
+ => ExpressiveResolver.FindGeneratedExpressionViaReflection(_methodWithParamsMember);
+
+ [Benchmark]
+ public object? ResolveViaReflection_Constructor()
+ => ExpressiveResolver.FindGeneratedExpressionViaReflection(_constructorMember);
+}
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj b/benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj
new file mode 100644
index 0000000..ab9ce4b
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/GeneratorBenchmarks.cs b/benchmarks/ExpressiveSharp.Benchmarks/GeneratorBenchmarks.cs
new file mode 100644
index 0000000..5893e38
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/GeneratorBenchmarks.cs
@@ -0,0 +1,78 @@
+using BenchmarkDotNet.Attributes;
+using ExpressiveSharp.Benchmarks.Helpers;
+using ExpressiveSharp.Generator;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Text;
+
+namespace ExpressiveSharp.Benchmarks;
+
+[MemoryDiagnoser]
+public class GeneratorBenchmarks
+{
+ [Params(1, 100, 1000)]
+ public int ExpressiveCount { get; set; }
+
+ private Compilation _compilation = null!;
+ private GeneratorDriver _warmedDriver = null!;
+ private Compilation _noiseModifiedCompilation = null!;
+ private Compilation _expressiveModifiedCompilation = null!;
+ private int _firstNoiseTreeIndex;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ var expressiveSources = BenchmarkCompilationHelper.BuildExpressiveSources(ExpressiveCount);
+ _firstNoiseTreeIndex = expressiveSources.Count;
+
+ _compilation = BenchmarkCompilationHelper.CreateCompilation(expressiveSources);
+
+ _warmedDriver = CSharpGeneratorDriver
+ .Create(new ExpressiveGenerator())
+ .RunGeneratorsAndUpdateCompilation(_compilation, out _, out _);
+
+ // Noise-modified: append comment to first noise tree
+ var noiseTree = _compilation.SyntaxTrees.ElementAt(_firstNoiseTreeIndex);
+ _noiseModifiedCompilation = _compilation.ReplaceSyntaxTree(
+ noiseTree,
+ noiseTree.WithChangedText(SourceText.From(noiseTree.GetText() + "\n// bench-edit")));
+
+ // Expressive-modified: append comment to first expressive tree
+ var expressiveTree = _compilation.SyntaxTrees.First();
+ _expressiveModifiedCompilation = _compilation.ReplaceSyntaxTree(
+ expressiveTree,
+ expressiveTree.WithChangedText(SourceText.From(expressiveTree.GetText() + "\n// bench-edit")));
+ }
+
+ // Cold benchmarks — brand-new driver per iteration
+
+ [Benchmark(Baseline = true)]
+ public GeneratorDriver RunGenerator()
+ => CSharpGeneratorDriver
+ .Create(new ExpressiveGenerator())
+ .RunGeneratorsAndUpdateCompilation(_compilation, out _, out _);
+
+ [Benchmark]
+ public GeneratorDriver RunGenerator_NoiseChange()
+ => CSharpGeneratorDriver
+ .Create(new ExpressiveGenerator())
+ .RunGeneratorsAndUpdateCompilation(_noiseModifiedCompilation, out _, out _);
+
+ [Benchmark]
+ public GeneratorDriver RunGenerator_ExpressiveChange()
+ => CSharpGeneratorDriver
+ .Create(new ExpressiveGenerator())
+ .RunGeneratorsAndUpdateCompilation(_expressiveModifiedCompilation, out _, out _);
+
+ // Incremental benchmarks — pre-warmed driver processes a single edit
+
+ [Benchmark]
+ public GeneratorDriver RunGenerator_Incremental_NoiseChange()
+ => _warmedDriver
+ .RunGeneratorsAndUpdateCompilation(_noiseModifiedCompilation, out _, out _);
+
+ [Benchmark]
+ public GeneratorDriver RunGenerator_Incremental_ExpressiveChange()
+ => _warmedDriver
+ .RunGeneratorsAndUpdateCompilation(_expressiveModifiedCompilation, out _, out _);
+}
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/Helpers/BenchmarkCompilationHelper.cs b/benchmarks/ExpressiveSharp.Benchmarks/Helpers/BenchmarkCompilationHelper.cs
new file mode 100644
index 0000000..cc78ebf
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/Helpers/BenchmarkCompilationHelper.cs
@@ -0,0 +1,220 @@
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace ExpressiveSharp.Benchmarks.Helpers;
+
+public static class BenchmarkCompilationHelper
+{
+ public static Compilation CreateCompilation(IReadOnlyList expressiveSources)
+ {
+ var noiseSources = BuildNoiseSources(expressiveSources.Count);
+ var allSources = expressiveSources.Concat(noiseSources);
+
+ var references = Basic.Reference.Assemblies.Net100.References.All.ToList();
+ references.Add(MetadataReference.CreateFromFile(typeof(ExpressiveAttribute).Assembly.Location));
+
+ return CSharpCompilation.Create(
+ "GeneratorBenchmarkInput",
+ allSources.Select((s, idx) => CSharpSyntaxTree.ParseText(s, path: $"File{idx}.cs")),
+ references,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+
+ ///
+ /// Splits members across multiple synthetic source
+ /// files so that each file holds between 1 and 9 members (one chunk per file).
+ /// Nine is the natural cycle length (one per emitter path).
+ ///
+ public static IReadOnlyList BuildExpressiveSources(int expressiveCount)
+ {
+ const int membersPerFile = 9;
+ var sources = new List();
+
+ var enumEmitted = false;
+ var fileIndex = 0;
+ for (var start = 0; start < expressiveCount; start += membersPerFile)
+ {
+ var count = Math.Min(membersPerFile, expressiveCount - start);
+ sources.Add(BuildOrderClassSource(fileIndex, start, count, emitEnum: !enumEmitted));
+ enumEmitted = true;
+ fileIndex++;
+ }
+
+ if (!enumEmitted)
+ {
+ sources.Add(
+ "using System;\n" +
+ "using ExpressiveSharp;\n" +
+ "namespace GeneratorBenchmarkInput;\n" +
+ "public enum OrderStatus { Pending, Active, Completed, Cancelled }\n");
+ }
+
+ var ctorCount = Math.Max(1, expressiveCount / 9);
+ for (var j = 0; j < ctorCount; j++)
+ {
+ sources.Add(BuildDtoClassSource(j));
+ }
+
+ return sources;
+ }
+
+ public static IReadOnlyList BuildNoiseSources(int count)
+ => Enumerable.Range(0, Math.Max(1, count))
+ .Select(BuildNoiseClassSource)
+ .ToList();
+
+ // Nine member kinds — one per emitter path in the generator:
+ // 0 simple string-concat property
+ // 1 boolean null-check property
+ // 2 single-param decimal method
+ // 3 multi-param string method
+ // 4 null-conditional property
+ // 5 switch-expression method
+ // 6 is-pattern property
+ // 7 block-bodied if/else chain
+ // 8 block-bodied switch with local var
+ private static string BuildOrderClassSource(int fileIndex, int startIndex, int count, bool emitEnum)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("using System;");
+ sb.AppendLine("using ExpressiveSharp;");
+ sb.AppendLine();
+ sb.AppendLine("namespace GeneratorBenchmarkInput;");
+ sb.AppendLine();
+
+ if (emitEnum)
+ {
+ sb.AppendLine("public enum OrderStatus { Pending, Active, Completed, Cancelled }");
+ sb.AppendLine();
+ }
+
+ sb.AppendLine($"public class Order{fileIndex}");
+ sb.AppendLine("{");
+ sb.AppendLine(" public string FirstName { get; set; } = string.Empty;");
+ sb.AppendLine(" public string LastName { get; set; } = string.Empty;");
+ sb.AppendLine(" public string? Email { get; set; }");
+ sb.AppendLine(" public decimal Amount { get; set; }");
+ sb.AppendLine(" public decimal TaxRate { get; set; }");
+ sb.AppendLine(" public DateTime? DeletedAt { get; set; }");
+ sb.AppendLine(" public bool IsEnabled { get; set; }");
+ sb.AppendLine(" public int Priority { get; set; }");
+ sb.AppendLine(" public OrderStatus Status { get; set; }");
+ sb.AppendLine();
+
+ for (var i = startIndex; i < startIndex + count; i++)
+ {
+ switch (i % 9)
+ {
+ case 0:
+ sb.AppendLine(" [Expressive]");
+ sb.AppendLine($" public string FullName{i} => $\"{{FirstName}} {{LastName}}\";");
+ break;
+ case 1:
+ sb.AppendLine(" [Expressive]");
+ sb.AppendLine($" public bool IsActive{i} => DeletedAt == null && IsEnabled;");
+ break;
+ case 2:
+ sb.AppendLine(" [Expressive]");
+ sb.AppendLine($" public decimal TotalWithTax{i}(decimal taxRate) => Amount * (1 + taxRate);");
+ break;
+ case 3:
+ sb.AppendLine(" [Expressive]");
+ sb.AppendLine($" public string FormatSummary{i}(string prefix, int count) => $\"{{prefix}}: {{FirstName}} x{{count}}\";");
+ break;
+ case 4:
+ sb.AppendLine(" [Expressive]");
+ sb.AppendLine($" public int? EmailLength{i} => Email?.Length;");
+ break;
+ case 5:
+ sb.AppendLine(" [Expressive]");
+ sb.AppendLine($" public string GetGrade{i}(int score) => score switch {{ >= 90 => \"A\", >= 70 => \"B\", _ => \"C\" }};");
+ break;
+ case 6:
+ sb.AppendLine(" [Expressive]");
+ sb.AppendLine($" public bool HasEmail{i} => Email is not null;");
+ break;
+ case 7:
+ sb.AppendLine(" [Expressive(AllowBlockBody = true)]");
+ sb.AppendLine($" public string GetStatusLabel{i}()");
+ sb.AppendLine(" {");
+ sb.AppendLine(" if (DeletedAt != null)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" return \"Deleted\";");
+ sb.AppendLine(" }");
+ sb.AppendLine(" if (IsEnabled)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" return $\"Active: {FirstName}\";");
+ sb.AppendLine(" }");
+ sb.AppendLine(" return \"Inactive\";");
+ sb.AppendLine(" }");
+ break;
+ case 8:
+ sb.AppendLine(" [Expressive(AllowBlockBody = true)]");
+ sb.AppendLine($" public string GetPriorityName{i}()");
+ sb.AppendLine(" {");
+ sb.AppendLine(" var p = Priority;");
+ sb.AppendLine(" switch (p)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" case 1: return \"Low\";");
+ sb.AppendLine(" case 2: return \"Medium\";");
+ sb.AppendLine(" case 3: return \"High\";");
+ sb.AppendLine(" default: return \"Unknown\";");
+ sb.AppendLine(" }");
+ sb.AppendLine(" }");
+ break;
+ }
+
+ sb.AppendLine();
+ }
+
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ private static string BuildDtoClassSource(int j)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("using System;");
+ sb.AppendLine("using ExpressiveSharp;");
+ sb.AppendLine();
+ sb.AppendLine("namespace GeneratorBenchmarkInput;");
+ sb.AppendLine();
+ sb.AppendLine($"public class OrderSummaryDto{j}");
+ sb.AppendLine("{");
+ sb.AppendLine(" public string FullName { get; set; } = string.Empty;");
+ sb.AppendLine(" public decimal Total { get; set; }");
+ sb.AppendLine(" public bool IsActive { get; set; }");
+ sb.AppendLine();
+ sb.AppendLine($" public OrderSummaryDto{j}() {{ }}");
+ sb.AppendLine();
+ sb.AppendLine(" [Expressive]");
+ sb.AppendLine($" public OrderSummaryDto{j}(string firstName, string lastName, decimal amount, decimal taxRate, bool isActive)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" FullName = $\"{firstName} {lastName}\";");
+ sb.AppendLine(" Total = amount * (1 + taxRate);");
+ sb.AppendLine(" IsActive = isActive && amount > 0;");
+ sb.AppendLine(" }");
+ sb.AppendLine("}");
+ sb.AppendLine();
+
+ return sb.ToString();
+ }
+
+ private static string BuildNoiseClassSource(int j)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("using System;");
+ sb.AppendLine("namespace GeneratorBenchmarkInput;");
+ sb.AppendLine();
+ sb.AppendLine($"public class NoiseEntity{j}");
+ sb.AppendLine("{");
+ sb.AppendLine(" public int Id { get; set; }");
+ sb.AppendLine(" public string Name { get; set; } = string.Empty;");
+ sb.AppendLine(" public DateTime CreatedAt { get; set; }");
+ sb.AppendLine(" public bool IsEnabled { get; set; }");
+ sb.AppendLine(" public decimal Amount { get; set; }");
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+}
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/Helpers/TestDbContext.cs b/benchmarks/ExpressiveSharp.Benchmarks/Helpers/TestDbContext.cs
new file mode 100644
index 0000000..7fb32a8
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/Helpers/TestDbContext.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace ExpressiveSharp.Benchmarks.Helpers;
+
+public class TestDbContext : DbContext
+{
+ private readonly bool _useExpressives;
+
+ public TestDbContext(bool useExpressives)
+ {
+ _useExpressives = useExpressives;
+ }
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ {
+ optionsBuilder.UseSqlite("Data Source=:memory:");
+ if (_useExpressives)
+ optionsBuilder.UseExpressives();
+ }
+
+ public DbSet Entities => Set();
+}
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/Helpers/TestEntity.cs b/benchmarks/ExpressiveSharp.Benchmarks/Helpers/TestEntity.cs
new file mode 100644
index 0000000..9d67a9f
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/Helpers/TestEntity.cs
@@ -0,0 +1,47 @@
+namespace ExpressiveSharp.Benchmarks.Helpers;
+
+public class TestEntity
+{
+ public int Id { get; set; }
+ public string FirstName { get; set; } = string.Empty;
+ public string LastName { get; set; } = string.Empty;
+ public string? Email { get; set; }
+ public decimal Amount { get; set; }
+ public decimal TaxRate { get; set; }
+ public DateTime? DeletedAt { get; set; }
+ public bool IsEnabled { get; set; }
+ public int Priority { get; set; }
+
+ [Expressive]
+ public int IdPlus1 => Id + 1;
+
+ [Expressive]
+ public string FullName => $"{FirstName} {LastName}";
+
+ [Expressive]
+ public int IdPlus1Method() => Id + 1;
+
+ [Expressive]
+ public int IdPlusDelta(int delta) => Id + delta;
+
+ [Expressive]
+ public int? EmailLength => Email?.Length;
+
+ [Expressive(AllowBlockBody = true)]
+ public string GetCategory()
+ {
+ if (DeletedAt != null) return "Deleted";
+ if (IsEnabled) return "Active";
+ return "Inactive";
+ }
+
+ [Expressive]
+ public TestEntity(TestEntity other)
+ {
+ Id = other.Id;
+ FirstName = other.FirstName;
+ LastName = other.LastName;
+ }
+
+ public TestEntity() { }
+}
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs b/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs
new file mode 100644
index 0000000..f50a9b3
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs
@@ -0,0 +1,101 @@
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using ExpressiveSharp.Generator;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Text;
+
+namespace ExpressiveSharp.Benchmarks;
+
+[MemoryDiagnoser]
+public class PolyfillGeneratorBenchmarks
+{
+ [Params(1, 100)]
+ public int CallSiteCount { get; set; }
+
+ private Compilation _compilation = null!;
+ private GeneratorDriver _warmedDriver = null!;
+ private Compilation _modifiedCompilation = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ var sources = BuildPolyfillSources(CallSiteCount);
+ _compilation = CreatePolyfillCompilation(sources);
+
+ _warmedDriver = CSharpGeneratorDriver
+ .Create(new PolyfillInterceptorGenerator())
+ .RunGeneratorsAndUpdateCompilation(_compilation, out _, out _);
+
+ var firstTree = _compilation.SyntaxTrees.First();
+ _modifiedCompilation = _compilation.ReplaceSyntaxTree(
+ firstTree,
+ firstTree.WithChangedText(SourceText.From(firstTree.GetText() + "\n// bench-edit")));
+ }
+
+ [Benchmark(Baseline = true)]
+ public GeneratorDriver RunGenerator()
+ => CSharpGeneratorDriver
+ .Create(new PolyfillInterceptorGenerator())
+ .RunGeneratorsAndUpdateCompilation(_compilation, out _, out _);
+
+ [Benchmark]
+ public GeneratorDriver RunGenerator_Incremental()
+ => _warmedDriver
+ .RunGeneratorsAndUpdateCompilation(_modifiedCompilation, out _, out _);
+
+ private static Compilation CreatePolyfillCompilation(IReadOnlyList sources)
+ {
+ var references = Basic.Reference.Assemblies.Net100.References.All.ToList();
+ references.Add(MetadataReference.CreateFromFile(typeof(ExpressiveAttribute).Assembly.Location));
+
+ return CSharpCompilation.Create(
+ "PolyfillBenchmarkInput",
+ sources.Select((s, idx) => CSharpSyntaxTree.ParseText(s, path: $"Polyfill{idx}.cs")),
+ references,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+
+ private static IReadOnlyList BuildPolyfillSources(int callSiteCount)
+ {
+ var sources = new List();
+
+ // Entity class
+ sources.Add(@"
+using System;
+using System.Linq;
+using ExpressiveSharp;
+
+namespace PolyfillBenchmarkInput;
+
+public class BenchEntity
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public decimal Amount { get; set; }
+}
+");
+
+ // Call sites — each in its own method to generate distinct interceptors
+ var sb = new StringBuilder();
+ sb.AppendLine("using System;");
+ sb.AppendLine("using System.Linq;");
+ sb.AppendLine("using ExpressiveSharp;");
+ sb.AppendLine();
+ sb.AppendLine("namespace PolyfillBenchmarkInput;");
+ sb.AppendLine();
+ sb.AppendLine("public static class Queries");
+ sb.AppendLine("{");
+
+ for (var i = 0; i < callSiteCount; i++)
+ {
+ sb.AppendLine($" public static IQueryable Query{i}(IRewritableQueryable q)");
+ sb.AppendLine($" => q.Select(x => x.Id + {i});");
+ }
+
+ sb.AppendLine("}");
+ sources.Add(sb.ToString());
+
+ return sources;
+ }
+}
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/Program.cs b/benchmarks/ExpressiveSharp.Benchmarks/Program.cs
new file mode 100644
index 0000000..76e0192
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/Program.cs
@@ -0,0 +1,8 @@
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Running;
+using ExpressiveSharp.Benchmarks;
+
+BenchmarkSwitcher
+ .FromAssembly(typeof(GeneratorBenchmarks).Assembly)
+ .Run(args, DefaultConfig.Instance
+ .WithOption(ConfigOptions.DisableOptimizationsValidator, true));
diff --git a/benchmarks/ExpressiveSharp.Benchmarks/TransformerBenchmarks.cs b/benchmarks/ExpressiveSharp.Benchmarks/TransformerBenchmarks.cs
new file mode 100644
index 0000000..d40f4ce
--- /dev/null
+++ b/benchmarks/ExpressiveSharp.Benchmarks/TransformerBenchmarks.cs
@@ -0,0 +1,65 @@
+using System.Linq.Expressions;
+using BenchmarkDotNet.Attributes;
+using ExpressiveSharp.Benchmarks.Helpers;
+using ExpressiveSharp.Extensions;
+using ExpressiveSharp.Services;
+using ExpressiveSharp.Transformers;
+
+namespace ExpressiveSharp.Benchmarks;
+
+[MemoryDiagnoser]
+public class TransformerBenchmarks
+{
+ private Expression _nullConditionalTree = null!;
+ private Expression _blockExpressionTree = null!;
+ private Expression _fullPipelineTree = null!;
+ private RemoveNullConditionalPatterns _removeNullConditional = null!;
+ private FlattenBlockExpressions _flattenBlocks = null!;
+ private ConvertLoopsToLinq _convertLoops = null!;
+ private FlattenTupleComparisons _flattenTuples = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _removeNullConditional = new RemoveNullConditionalPatterns();
+ _flattenBlocks = new FlattenBlockExpressions();
+ _convertLoops = new ConvertLoopsToLinq();
+ _flattenTuples = new FlattenTupleComparisons();
+
+ // Build expression trees by first replacing [Expressive] members,
+ // then feeding the raw expanded tree (pre-transform) to each transformer.
+ var replacer = new ExpressiveReplacer(new ExpressiveResolver());
+
+ Expression> nullCondExpr = e => e.EmailLength;
+ _nullConditionalTree = replacer.Replace(nullCondExpr);
+
+ replacer = new ExpressiveReplacer(new ExpressiveResolver());
+ Expression> blockExpr = e => e.GetCategory();
+ _blockExpressionTree = replacer.Replace(blockExpr);
+
+ // Full pipeline test: expression with multiple [Expressive] usages
+ Expression> fullExpr = e =>
+ $"{e.FullName} ({e.GetCategory()}) #{e.IdPlus1Method()}";
+ _fullPipelineTree = fullExpr;
+ }
+
+ [Benchmark]
+ public Expression Transform_RemoveNullConditionalPatterns()
+ => _removeNullConditional.Transform(_nullConditionalTree);
+
+ [Benchmark]
+ public Expression Transform_FlattenBlockExpressions()
+ => _flattenBlocks.Transform(_blockExpressionTree);
+
+ [Benchmark]
+ public Expression Transform_ConvertLoopsToLinq()
+ => _convertLoops.Transform(_blockExpressionTree);
+
+ [Benchmark]
+ public Expression Transform_FlattenTupleComparisons()
+ => _flattenTuples.Transform(_nullConditionalTree);
+
+ [Benchmark(Baseline = true)]
+ public Expression ExpandExpressives_FullPipeline()
+ => _fullPipelineTree.ExpandExpressives();
+}
diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md
index 13c8541..9b47a1e 100644
--- a/docs/testing-strategy.md
+++ b/docs/testing-strategy.md
@@ -89,6 +89,40 @@ When implementing a new `EmitOperation` case:
compatibility, add tests in `ExpressiveSharp.Tests/Transformers/` that build expression
trees manually and verify the transformer rewrites them correctly.
+## Performance Benchmarks
+
+Beyond correctness testing, the project includes BenchmarkDotNet benchmarks in
+`benchmarks/ExpressiveSharp.Benchmarks/` that track performance across key hot paths:
+
+| Benchmark class | What it measures |
+|-----------------|-----------------|
+| `GeneratorBenchmarks` | Cold and incremental `ExpressiveGenerator` runs (parameterized by member count) |
+| `PolyfillGeneratorBenchmarks` | Cold and incremental `PolyfillInterceptorGenerator` runs |
+| `ExpressionResolverBenchmarks` | Registry vs. reflection lookup for properties, methods, constructors |
+| `ExpressionReplacerBenchmarks` | `ExpressiveReplacer.Replace` on various expression tree shapes |
+| `TransformerBenchmarks` | Each transformer in isolation + full `ExpandExpressives` pipeline |
+| `EFCoreQueryOverheadBenchmarks` | End-to-end EF Core `ToQueryString()` overhead + cold-start cost |
+
+### CI Regression Detection
+
+A separate GitHub Actions workflow (`.github/workflows/benchmarks.yml`) runs benchmarks on every
+push to `main` and on pull requests. Results are stored on the `gh-pages` branch and compared
+against the last `main` baseline using `benchmark-action/github-action-benchmark`. PRs that
+regress beyond 20% receive an automated comment.
+
+### Running Benchmarks Locally
+
+```bash
+# Run all benchmarks (full BenchmarkDotNet defaults)
+dotnet run -c Release --project benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj -- --filter "*"
+
+# Run a specific class
+dotnet run -c Release --project benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj -- --filter "*GeneratorBenchmarks*"
+
+# Quick run (CI-style, fewer iterations)
+dotnet run -c Release --project benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj -- --filter "*" --job short --iterationCount 3 --warmupCount 1
+```
+
## Running Tests
```bash