diff --git a/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs b/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs index 244bcd8..643a716 100644 --- a/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs +++ b/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs @@ -288,3 +288,129 @@ public static class BenchHelper { public static string Describe(int id) => $""#{ return sources; } } + +/// +/// Cold-build cost in a codebase where query files are mixed with ordinary non-lambda +/// member-access calls (sb.Append(x), list.Add(x), Console.WriteLine) +/// — the shape of real production code. +/// +/// Fixed 10 files × 5 real call sites per file; +/// controls how many non-intercept invocations sit alongside them. The gap between +/// NoiseInvocationsPerFile = 0 and higher values reflects how well the generator +/// filters out invocations it cannot intercept. +/// +/// +[MemoryDiagnoser] +public class PolyfillColdBuildWithNoiseBenchmarks +{ + [Params(0, 25, 100)] + public int NoiseInvocationsPerFile { get; set; } + + private const int FileCount = 10; + private const int CallSitesPerFile = 5; + + private Compilation _compilation = null!; + private GeneratorDriver _warmedDriver = null!; + private Compilation _callSiteFileModified = null!; + + [GlobalSetup] + public void Setup() + { + var sources = BuildSources(FileCount, CallSitesPerFile, NoiseInvocationsPerFile); + _compilation = CreateCompilation(sources); + + _warmedDriver = CSharpGeneratorDriver + .Create(new PolyfillInterceptorGenerator()) + .RunGenerators(_compilation); + + // Index FileCount = last query file (has CallSitesPerFile real call sites + noise) + var callSiteTree = _compilation.SyntaxTrees.ElementAt(FileCount); + _callSiteFileModified = _compilation.ReplaceSyntaxTree( + callSiteTree, + callSiteTree.WithChangedText(SourceText.From(callSiteTree.GetText() + "\n// bench-edit"))); + } + + // ── Generator-only (no compilation rebuild) ────────────────────────────── + + [Benchmark(Baseline = true)] + public GeneratorDriver Cold() + => CSharpGeneratorDriver + .Create(new PolyfillInterceptorGenerator()) + .RunGenerators(_compilation); + + [Benchmark] + public GeneratorDriver Incremental_EditCallSiteFile() + => _warmedDriver.RunGenerators(_callSiteFileModified); + + // ── End-to-end (generator + Roslyn compilation rebuild) ────────────────── + + [Benchmark] + public GeneratorDriver Cold_E2E() + => CSharpGeneratorDriver + .Create(new PolyfillInterceptorGenerator()) + .RunGeneratorsAndUpdateCompilation(_compilation, out _, out _); + + [Benchmark] + public GeneratorDriver Incremental_EditCallSiteFile_E2E() + => _warmedDriver.RunGeneratorsAndUpdateCompilation(_callSiteFileModified, out _, out _); + + private static Compilation CreateCompilation(IReadOnlyList sources) + { + var refs = Basic.Reference.Assemblies.Net100.References.All.ToList(); + refs.Add(MetadataReference.CreateFromFile(typeof(ExpressiveAttribute).Assembly.Location)); + return CSharpCompilation.Create( + "PolyfillColdBuildNoiseBench", + sources.Select((s, i) => CSharpSyntaxTree.ParseText(s, path: $"PolyfillNoise{i}.cs")), + refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + private static IReadOnlyList BuildSources(int fileCount, int sitesPerFile, int noisePerFile) + { + // index 0: entity + var sources = new List + { + @"using System.Linq; using ExpressiveSharp; namespace PolyfillBench; +public class BenchEntity { public int Id { get; set; } public string Name { get; set; } = """"; }" + }; + + // indices 1..fileCount: query files (real call sites + noise invocations) + var sb = new StringBuilder(); + for (var f = 0; f < fileCount; f++) + { + sb.Clear(); + sb.AppendLine("using System; using System.Linq; using System.Text; using System.Collections.Generic; using ExpressiveSharp;"); + sb.AppendLine("namespace PolyfillBench;"); + sb.AppendLine($"public static class NoisyQueries{f} {{"); + + for (var i = 0; i < sitesPerFile; i++) + sb.AppendLine($" public static IQueryable Q{f}_{i}(IExpressiveQueryable q) => q.Select(x => x.Id + {f * 100 + i});"); + + if (noisePerFile > 0) + { + sb.AppendLine($" public static string Helper{f}() {{"); + sb.AppendLine(" var buf = new StringBuilder();"); + sb.AppendLine(" var items = new List();"); + for (var n = 0; n < noisePerFile; n++) + { + // Rotate through common call shapes so the sample isn't pathologically uniform. + switch (n % 5) + { + case 0: sb.AppendLine($" buf.Append(\"a{n}\");"); break; + case 1: sb.AppendLine($" buf.AppendLine(\"line{n}\");"); break; + case 2: sb.AppendLine($" items.Add({n});"); break; + case 3: sb.AppendLine($" Console.WriteLine(\"msg{n}\");"); break; + case 4: sb.AppendLine($" buf.Insert(0, {n}.ToString());"); break; + } + } + sb.AppendLine(" return buf.ToString();"); + sb.AppendLine(" }"); + } + + sb.AppendLine("}"); + sources.Add(sb.ToString()); + } + + return sources; + } +} diff --git a/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs b/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs index e211d71..3d02c3f 100644 --- a/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs +++ b/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs @@ -105,12 +105,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { ct.ThrowIfCancellationRequested(); var unit = (CompilationUnitSyntax)ctx.Node; - // Quick syntactic pre-filter: any member-access invocation with ≥1 argument? + // Every intercepted call site (Polyfill.Create + IExpressiveQueryable stubs) + // takes at least one lambda argument. Files with no lambda-bearing invocations + // can't contain anything we'd intercept, so drop them before semantic binding. foreach (var descendant in unit.DescendantNodes()) { if (descendant is InvocationExpressionSyntax inv && inv.Expression is MemberAccessExpressionSyntax && - inv.ArgumentList.Arguments.Count > 0) + HasLambdaArgument(inv)) return unit; } return null; @@ -162,8 +164,8 @@ private static void ProcessFileAndEmit( foreach (var descendant in unit.DescendantNodes()) { if (descendant is not InvocationExpressionSyntax inv) continue; - if (inv.Expression is not MemberAccessExpressionSyntax) continue; - if (inv.ArgumentList.Arguments.Count == 0) continue; + if (inv.Expression is not MemberAccessExpressionSyntax ma) continue; + if (!HasLambdaArgument(inv)) continue; ct.ThrowIfCancellationRequested(); try @@ -171,13 +173,25 @@ private static void ProcessFileAndEmit( // Use the METHOD NAME token position for stable, unique naming. // Using the invocation's span-start would collide in chained calls // (e.g. query.AsExpressive().Where(…) — both start at 'query'). - var ma = (MemberAccessExpressionSyntax)inv.Expression; var nameLineSpan = ma.Name.GetLocation().GetLineSpan(); var line = nameLineSpan.StartLinePosition.Line; var col = nameLineSpan.StartLinePosition.Character; - var methodCode = TryEmitPolyfill(inv, model, line, col, fileTag, spc) - ?? TryEmit(inv, model, line, col, fileTag, spc); + // Exclusive dispatch: ExpressionPolyfill.Create takes the polyfill path, + // everything else takes the IExpressiveQueryable path. + if (model.GetSymbolInfo(inv).Symbol is not IMethodSymbol method) continue; + + string? methodCode; + if (ma.Name.Identifier.Text == PolyfillMethodName && + method.ContainingType?.ToDisplayString() == PolyfillTypeName) + { + methodCode = TryEmitPolyfill(inv, model, method, line, col, fileTag, spc); + } + else + { + methodCode = TryEmit(inv, ma, model, method, line, col, fileTag, spc); + } + if (methodCode is null) continue; methodCodes.Add(methodCode); @@ -271,19 +285,13 @@ private static string GetFileTag(string sourcePath) /// private static string? TryEmitPolyfill(InvocationExpressionSyntax inv, SemanticModel model, + IMethodSymbol method, int line, int col, string fileTag, SourceProductionContext spc) { - if (model.GetSymbolInfo(inv).Symbol is not IMethodSymbol method) - return null; - - // Must be ExpressionPolyfill.Create(...) - if (method.Name != PolyfillMethodName) - return null; - if (method.ContainingType?.ToDisplayString() != PolyfillTypeName) - return null; + // Caller guarantees method is ExpressionPolyfill.Create. if (method.TypeArguments.Length != 1) return null; @@ -352,14 +360,14 @@ private static string GetFileTag(string sourcePath) // ── Per-invocation dispatch (IExpressiveQueryable) ─────────────────────── private static string? TryEmit(InvocationExpressionSyntax inv, + MemberAccessExpressionSyntax ma, SemanticModel model, + IMethodSymbol method, int line, int col, string fileTag, SourceProductionContext spc) { - var ma = (MemberAccessExpressionSyntax)inv.Expression; - // Receiver must be or implement IExpressiveQueryable. if (model.GetTypeInfo(ma.Expression).Type is not INamedTypeSymbol receiverType) return null; @@ -368,9 +376,6 @@ private static string GetFileTag(string sourcePath) if (!IsExpressiveQueryable(receiverType)) return null; - if (model.GetSymbolInfo(inv).Symbol is not IMethodSymbol method) - return null; - // The stub convention: at least one non-receiver parameter must be a Func<> delegate, // not an Expression>. This distinguishes user/library IExpressiveQueryable stubs // from regular IQueryable extension methods. @@ -520,6 +525,17 @@ private static bool IsAnonymousType(ITypeSymbol type) // ── Formatting helpers ─────────────────────────────────────────────────── + private static bool HasLambdaArgument(InvocationExpressionSyntax inv) + { + var args = inv.ArgumentList.Arguments; + for (var i = 0; i < args.Count; i++) + { + if (args[i].Expression is LambdaExpressionSyntax) + return true; + } + return false; + } + /// /// Produces a unique, stable interceptor method name based on the call site's /// source file hash + method-name-token position (line/col).