Skip to content

perf(polyfill): skip semantic binding on non-lambda invocations#42

Merged
koenbeuk merged 1 commit intomainfrom
feat/polyfill-cold-build-optimization
Apr 24, 2026
Merged

perf(polyfill): skip semantic binding on non-lambda invocations#42
koenbeuk merged 1 commit intomainfrom
feat/polyfill-cold-build-optimization

Conversation

@koenbeuk
Copy link
Copy Markdown
Collaborator

Summary

  • Narrow the PolyfillInterceptorGenerator syntactic predicate to require a lambda argument before engaging the SemanticModel. Non-lambda member-access calls (sb.Append, list.Add, Console.WriteLine, …) are filtered out before any GetSymbolInfo / GetTypeInfo.
  • Bind the method symbol once per surviving invocation and dispatch exclusively (ExpressionPolyfill.Create vs IExpressiveQueryable), gated by a cheap method-name-token check for the polyfill path.
  • Add PolyfillColdBuildWithNoiseBenchmarks covering the case the existing benches can't: real call sites mixed with ordinary non-intercept invocations (the shape of production code).

Why

PR #41 fixed incremental IDE edits via reference-equality caching on CompilationUnitSyntax. It does not fix two other paths:

  1. Cold builds (first compile, CI, post-clean) — every transform runs from scratch.
  2. Edits to files that contain call sites — full file re-enters the transform.

On both, the pre-existing predicate fired GetSymbolInfo on every MemberAccessExpressionSyntax invocation with ≥1 arg, including tens of thousands of ordinary non-LINQ calls. Every intercepted call site the generator actually emits takes a lambda, so requiring a lambda syntactically is a safe, strong filter.

Results

Measured with PolyfillColdBuildWithNoiseBenchmarks (10 files × 5 real call sites, NoiseInvocationsPerFile varied):

Scenario Noise=0 Noise=25 Noise=100
Cold baseline 3,489 µs 10,858 µs 81,511 µs
Cold optimized 3,414 µs 3,711 µs 4,171 µs
Speedup 1.0× 2.9× 19.5×
Cold_E2E baseline 3,580 µs 11,354 µs 155,758 µs
Cold_E2E optimized 3,469 µs 3,655 µs 4,150 µs
Speedup 1.0× 3.1× 37.5×
Incremental_EditCallSiteFile baseline 386 µs 2,125 µs 5,385 µs
Incremental_EditCallSiteFile optimized 409 µs 419 µs 463 µs

Memory at noise=100: 5,538 KB → 1,965 KB (−64 %).

At noise=0 everything is within measurement noise, so PR #41's incremental-edit win is preserved intact.

Safety

  • Method-group args (query.Select(SomeMethod)) and Func<>-variable args (query.Select(f)) were never intercepted — both TryEmitPolyfill and EmitGenericLambda already returned null for them, and the stubs throw UnreachableException at runtime. The tighter predicate drops these one step earlier; observable behavior is identical.
  • EXP0013 (warns on non-[Expressive] method groups) lives in ExpressiveSharp.CodeFixers, an independent analyzer. Unaffected.
  • Full test suites pass: generator snapshot tests (1239), ExpressiveSharp.IntegrationTests (228), ExpressiveSharp.EntityFrameworkCore.IntegrationTests (562). Every .verified.txt matches byte-for-byte, so the intercepted set and emitted code are unchanged.

Test plan

  • dotnet test tests/ExpressiveSharp.Generator.Tests — 1239 passed (net8/9/10)
  • dotnet test tests/ExpressiveSharp.IntegrationTests — 228 passed
  • dotnet test tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests — 562 passed
  • PolyfillColdBuildWithNoiseBenchmarks baseline + optimized run on the same machine, numbers above
  • PolyfillSingleFileBenchmarks + PolyfillMultiFileBenchmarks spot-checked — no regression

🤖 Generated with Claude Code

Narrow the syntactic predicate to require at least one lambda argument
before reaching for the SemanticModel. Every intercepted call site
(ExpressionPolyfill.Create and the IExpressiveQueryable stubs) takes a
lambda; method-group and Func<>-variable args were never intercepted.
Ordinary non-lambda member-access calls (sb.Append, list.Add, etc.) now
drop out at the predicate level instead of paying per-invocation
GetSymbolInfo + GetTypeInfo cost on every cold build.

Also consolidate the per-invocation symbol binding into a single
exclusive dispatch (ExpressionPolyfill.Create vs IExpressiveQueryable),
and gate the polyfill path behind a cheap method-name-token check so
non-"Create" invocations never enter that branch.

Add PolyfillColdBuildWithNoiseBenchmarks to exercise the case the
existing benchmarks can't: files containing a mix of real call sites
and ordinary non-intercept invocations. On the new bench at 100 noise
invocations per file (10 files x 5 real call sites):

  Cold:     81.5ms -> 4.2ms  (19.5x)
  Cold_E2E: 155.8ms -> 4.2ms (37.5x)
  Incremental_EditCallSiteFile: 5.4ms -> 0.46ms (11.6x)

At 0 noise invocations the numbers are within measurement noise of
pre-change, so the incremental-edit cost PR #41 drove down is preserved.
Allocation at noise=100 drops 64% (5.5MB -> 2.0MB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 24, 2026 01:04
Comment on lines +185 to +193
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);
}
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 24, 2026

Codecov Report

❌ Patch coverage is 87.50000% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...iveSharp.Generator/PolyfillInterceptorGenerator.cs 87.50% 0 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes PolyfillInterceptorGenerator cold-build performance by tightening the syntactic predicate so the generator only engages semantic binding for invocation sites that can actually be intercepted (i.e., invocations with at least one lambda argument). It also adds a benchmark that better represents “real code” by mixing intercepted call sites with large volumes of ordinary member-access invocations.

Changes:

  • Require a syntactic lambda argument (HasLambdaArgument) before treating an invocation as a candidate, both at the file prefilter stage and during per-invocation processing.
  • Bind the invocation’s IMethodSymbol once per surviving invocation and use exclusive dispatch (polyfill vs IExpressiveQueryable) to avoid redundant symbol binding.
  • Add PolyfillColdBuildWithNoiseBenchmarks to measure cold-build and incremental-edit behavior with realistic “noise” invocations.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs Narrows candidate detection to lambda-bearing invocations and binds method symbols once for exclusive dispatch.
benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs Adds a new benchmark modeling mixed real call sites + non-intercept member-access noise.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 285 to 286
/// </summary>
private static string? TryEmitPolyfill(InvocationExpressionSyntax inv,
@koenbeuk koenbeuk merged commit af72c8d into main Apr 24, 2026
24 checks passed
@koenbeuk koenbeuk deleted the feat/polyfill-cold-build-optimization branch April 24, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants