Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,129 @@ public static class BenchHelper { public static string Describe(int id) => $""#{
return sources;
}
}

/// <summary>
/// Cold-build cost in a codebase where query files are mixed with ordinary non-lambda
/// member-access calls (<c>sb.Append(x)</c>, <c>list.Add(x)</c>, <c>Console.WriteLine</c>)
/// — the shape of real production code.
/// <para>
/// Fixed 10 files × 5 real call sites per file; <see cref="NoiseInvocationsPerFile"/>
/// controls how many non-intercept invocations sit alongside them. The gap between
/// <c>NoiseInvocationsPerFile = 0</c> and higher values reflects how well the generator
/// filters out invocations it cannot intercept.
/// </para>
/// </summary>
[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<string> 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<string> BuildSources(int fileCount, int sitesPerFile, int noisePerFile)
{
// index 0: entity
var sources = new List<string>
{
@"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<int> Q{f}_{i}(IExpressiveQueryable<BenchEntity> 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<int>();");
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;
}
}
56 changes: 36 additions & 20 deletions src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -162,22 +164,34 @@ 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
{
// 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<T> 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);
}
Comment on lines +185 to +193

if (methodCode is null) continue;

methodCodes.Add(methodCode);
Expand Down Expand Up @@ -271,19 +285,13 @@ private static string GetFileTag(string sourcePath)
/// </summary>
private static string? TryEmitPolyfill(InvocationExpressionSyntax inv,
Comment on lines 285 to 286
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<TDelegate>(...)
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;

Expand Down Expand Up @@ -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<T>.
if (model.GetTypeInfo(ma.Expression).Type is not INamedTypeSymbol receiverType)
return null;
Expand All @@ -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<Func<>>. This distinguishes user/library IExpressiveQueryable<T> stubs
// from regular IQueryable<T> extension methods.
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Produces a unique, stable interceptor method name based on the call site's
/// source file hash + method-name-token position (line/col).
Expand Down
Loading