From 0feefe7dcb7409f3539d9d86d09adf45ab80f943 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 25 Mar 2026 23:54:24 +0000 Subject: [PATCH 1/5] feat: Add BenchmarkDotNet benchmarks for performance evaluation and CI integration --- .github/workflows/benchmarks.yml | 71 ++++++ CLAUDE.md | 7 + Directory.Packages.props | 1 + ExpressiveSharp.slnx | 3 + benchmarks/Directory.Build.props | 7 + .../EFCoreQueryOverheadBenchmarks.cs | 60 +++++ .../ExpressionReplacerBenchmarks.cs | 59 +++++ .../ExpressionResolverBenchmarks.cs | 66 ++++++ .../ExpressiveSharp.Benchmarks.csproj | 22 ++ .../GeneratorBenchmarks.cs | 78 +++++++ .../Helpers/BenchmarkCompilationHelper.cs | 220 ++++++++++++++++++ .../Helpers/TestDbContext.cs | 22 ++ .../Helpers/TestEntity.cs | 47 ++++ .../PolyfillGeneratorBenchmarks.cs | 101 ++++++++ .../ExpressiveSharp.Benchmarks/Program.cs | 8 + .../TransformerBenchmarks.cs | 65 ++++++ docs/testing-strategy.md | 34 +++ 17 files changed, 871 insertions(+) create mode 100644 .github/workflows/benchmarks.yml create mode 100644 benchmarks/Directory.Build.props create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/EFCoreQueryOverheadBenchmarks.cs create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/ExpressionReplacerBenchmarks.cs create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/ExpressionResolverBenchmarks.cs create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/GeneratorBenchmarks.cs create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/Helpers/BenchmarkCompilationHelper.cs create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/Helpers/TestDbContext.cs create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/Helpers/TestEntity.cs create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/Program.cs create mode 100644 benchmarks/ExpressiveSharp.Benchmarks/TransformerBenchmarks.cs diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..1285d63 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,71 @@ +name: Benchmarks + +on: + push: + branches: [main] + pull_request: + branches: [main] + +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 + --job short + --iterationCount 3 + --warmupCount 1 + + - name: Find benchmark result + id: find-result + run: echo "file=$(find BenchmarkDotNet.Artifacts -name '*-report-full.json' | head -1)" >> $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..4f51ed2 --- /dev/null +++ b/benchmarks/ExpressiveSharp.Benchmarks/EFCoreQueryOverheadBenchmarks.cs @@ -0,0 +1,60 @@ +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 WithExpressives_BlockBody() + => _expressiveCtx.Entities.Select(x => x.GetCategory()).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..565bc8f --- /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 From 4c4a30c96fec08c4a30e35062bd8cf14560141d2 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 25 Mar 2026 23:56:20 +0000 Subject: [PATCH 2/5] feat: Enable manual triggering of benchmark workflow with workflow_dispatch --- .github/workflows/benchmarks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 1285d63..89745d0 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: permissions: contents: write From a1fe7c43e7026665c8cc1d4b99fd0b0b2018388a Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 26 Mar 2026 00:49:57 +0000 Subject: [PATCH 3/5] feat: Improve benchmark workflow by adding error handling and updating result file search --- .github/workflows/benchmarks.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 89745d0..a66fb43 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -49,13 +49,20 @@ jobs: -- --filter "*" --exporters json + --join --job short --iterationCount 3 --warmupCount 1 - name: Find benchmark result id: find-result - run: echo "file=$(find BenchmarkDotNet.Artifacts -name '*-report-full.json' | head -1)" >> $GITHUB_OUTPUT + 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 From 5c12a51f6708cca518ba34d68f138053e58e0d35 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 26 Mar 2026 00:53:13 +0000 Subject: [PATCH 4/5] Update benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ExpressiveSharp.Benchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj b/benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj index 565bc8f..ab9ce4b 100644 --- a/benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj +++ b/benchmarks/ExpressiveSharp.Benchmarks/ExpressiveSharp.Benchmarks.csproj @@ -8,7 +8,7 @@ - + From ca55511628309743824416dd422272635dd93b6a Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 26 Mar 2026 01:59:20 +0000 Subject: [PATCH 5/5] feat: Remove redundant benchmark for WithExpressives_BlockBody --- .../EFCoreQueryOverheadBenchmarks.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/benchmarks/ExpressiveSharp.Benchmarks/EFCoreQueryOverheadBenchmarks.cs b/benchmarks/ExpressiveSharp.Benchmarks/EFCoreQueryOverheadBenchmarks.cs index 4f51ed2..2016aae 100644 --- a/benchmarks/ExpressiveSharp.Benchmarks/EFCoreQueryOverheadBenchmarks.cs +++ b/benchmarks/ExpressiveSharp.Benchmarks/EFCoreQueryOverheadBenchmarks.cs @@ -40,10 +40,6 @@ public string WithExpressives_Method() public string WithExpressives_NullConditional() => _expressiveCtx.Entities.Select(x => x.EmailLength).ToQueryString(); - [Benchmark] - public string WithExpressives_BlockBody() - => _expressiveCtx.Entities.Select(x => x.GetCategory()).ToQueryString(); - [Benchmark] public string ColdStart_WithExpressives() {