Skip to content
21 changes: 15 additions & 6 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,35 @@ jobs:
run: cd Benchmarking/Benchmarks && dotnet run -c Release --exporters json --filter '*'

- name: Store runtime benchmark result
uses: rhysd/github-action-benchmark@v1
uses: benchmark-action/github-action-benchmark@v1
with:
name: Benchmark.Net Benchmark
tool: 'benchmarkdotnet'
output-file-path: Benchmarking/Benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.ResolveBenchmarks-report-full-compressed.json
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
summary-always: true
# Show alert with commit comment on detecting possible performance regression
alert-threshold: '200%'
comment-on-alert: true
comment-on-alert: false
fail-on-alert: false
auto-push: true

- name: Reset gh-pages branch for second benchmark
run: |
git fetch origin gh-pages:gh-pages --force || true

- name: Store generator benchmark result
uses: rhysd/github-action-benchmark@v1
uses: benchmark-action/github-action-benchmark@v1
with:
name: Generator Benchmark.Net Benchmark
tool: 'benchmarkdotnet'
output-file-path: Benchmarking/Benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.GeneratorBenchmarks-report-full-compressed.json
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
summary-always: true
alert-threshold: '200%'
comment-on-alert: true
comment-on-alert: false
fail-on-alert: false
# The prior step already fetched and locally committed to gh-pages in this same job;
# re-fetching here would be rejected as non-fast-forward against that local commit.
skip-fetch-gh-pages: true
auto-push: true
37 changes: 0 additions & 37 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,40 +101,3 @@ jobs:
dotnet nuget push $file --api-key "${{ secrets.NUGET_APIKEY }}" --source https://api.nuget.org/v3/index.json
done


benchmark:
name: Performance regression check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup dotnet ${{ matrix.dotnet-version }}
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Run benchmark
run: cd Benchmarking/Benchmarks && dotnet run -c Release --exporters json --filter '*'

- name: Store runtime benchmark result
uses: rhysd/github-action-benchmark@v1
with:
name: Benchmark.Net Benchmark
tool: 'benchmarkdotnet'
output-file-path: Benchmarking/Benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.ResolveBenchmarks-report-full-compressed.json
github-token: ${{ secrets.GITHUB_TOKEN }}
summary-always: true
# Show alert with commit comment on detecting possible performance regression
alert-threshold: '200%'
comment-on-alert: false
fail-on-alert: false

- name: Store generator benchmark result
uses: rhysd/github-action-benchmark@v1
with:
name: Generator Benchmark.Net Benchmark
tool: 'benchmarkdotnet'
output-file-path: Benchmarking/Benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.GeneratorBenchmarks-report-full-compressed.json
github-token: ${{ secrets.GITHUB_TOKEN }}
summary-always: true
alert-threshold: '200%'
comment-on-alert: false
fail-on-alert: false
64 changes: 64 additions & 0 deletions Benchmarking/Benchmarks/BenchmarkConfigs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using Perfolizer.Mathematics.OutlierDetection;

namespace Benchmarks;

/// <summary>
/// Job configuration for the source-generator ("cold start") benchmarks in <see cref="GeneratorBenchmarks"/>.
///
/// Each Cold_* benchmark drives a full Roslyn compilation plus a generator run, costing anywhere from
/// ~1ms to ~20ms per invocation. The previous <c>[ShortRunJob]</c> preset fixed the sample count at
/// exactly 3 iterations (after 3 warmups), which is far too few at this scale: GC pauses, JIT tiering,
/// and OS thread-scheduling noise are all large relative to a single iteration — several Cold_*
/// results measured a standard error larger than the mean itself. This config instead:
/// - keeps a single process launch (<c>LaunchCount=1</c>) — relaunching the whole process mainly
/// re-pays JIT/compilation startup cost, which the warmup stage already amortizes, so a second
/// launch buys little extra accuracy for a much longer total run;
/// - increases warmup to 6 iterations so the JIT has fully tiered up before measurement begins;
/// - replaces the fixed iteration count with an adaptive 15-30 range, giving the engine enough
/// samples to converge on a stable estimate instead of stopping after 3;
/// - removes outliers on both sides (<see cref="OutlierMode.RemoveAll"/>), since GC/JIT blips can
/// push individual iterations slower (common) or faster (rarer) than the true steady-state cost.
/// </summary>
public sealed class AccurateColdStartConfig : ManualConfig
{
public AccurateColdStartConfig()
{
AddJob(new Job("Accurate")
.WithLaunchCount(1)
.WithWarmupCount(6)
.WithMinIterationCount(15)
.WithMaxIterationCount(30)
.WithOutlierMode(OutlierMode.RemoveAll));
}
}

/// <summary>
/// Job configuration for the runtime resolve/construction micro-benchmarks in <see cref="ResolveBenchmarks"/>.
///
/// These benchmarks measure single-digit-to-low-hundreds of nanoseconds per call, so BenchmarkDotNet
/// unrolls each iteration into millions of invocations. At that scale, a single background GC
/// collection (workstation GC's concurrent/background mode can run mid-measurement) is enough to
/// visibly skew an iteration — this is exactly what showed up as periodic outlier spikes (e.g.
/// ResolveChain jumping from ~55ns to 100+ns on isolated iterations) in earlier runs. This config:
/// - disables concurrent/background GC (<c>WithGcConcurrent(false)</c>)
/// so a collection cannot preempt a measurement iteration on a background thread; workstation GC
/// still runs non-concurrently, it simply can no longer interrupt the benchmarked thread mid-iteration;
/// - widens the iteration bounds (15-25) so the dynamic stopping criteria has more samples to work
/// with before it decides the estimate has converged;
/// - removes outliers on both sides (<see cref="OutlierMode.RemoveAll"/>) to further suppress any
/// remaining scheduling noise.
/// </summary>
public sealed class AccurateMicroBenchmarkConfig : ManualConfig
{
public AccurateMicroBenchmarkConfig()
{
AddJob(new Job("Accurate")
.WithGcServer(false)
.WithGcConcurrent(false)
.WithMinIterationCount(15)
.WithMaxIterationCount(25)
.WithOutlierMode(OutlierMode.RemoveAll));
}
}
133 changes: 132 additions & 1 deletion Benchmarking/Benchmarks/GeneratorBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,55 @@
namespace Benchmarks;

[MemoryDiagnoser]
[ShortRunJob]
[Config(typeof(AccurateColdStartConfig))]
[JsonExporterAttribute.Full]
[JsonExporterAttribute.FullCompressed]
public class GeneratorBenchmarks
{
private ColdGeneratorScenario m_constructorGraph = null!;
private ColdGeneratorScenario m_constructorGraphWithStaticExtensions = null!;
private ColdGeneratorScenario m_noiseHeavyProject = null!;
private ColdGeneratorScenario m_featureRichStaticExtensionsDisabled = null!;
private ColdGeneratorScenario m_featureRichStaticExtensionsEnabled = null!;
private ColdGeneratorScenario m_multiAssemblyOverrideGraph = null!;
private ColdGeneratorScenario m_manyAssembliesGraph = null!;
private FeatureRichIncrementalScenario m_featureRichIncremental = null!;
private IncrementalGeneratorScenario m_referenceAssemblyIncremental = null!;

[GlobalSetup]
public void Setup()
{
m_constructorGraph = GeneratorBenchmarkScenarioFactory.CreateConstructorGraph(serviceCount: 250);
m_constructorGraphWithStaticExtensions = GeneratorBenchmarkScenarioFactory.CreateConstructorGraphWithStaticExtensions(serviceCount: 250);
m_noiseHeavyProject = GeneratorBenchmarkScenarioFactory.CreateNoiseHeavyProject(serviceCount: 64, noiseTypeCount: 2000);
m_featureRichStaticExtensionsDisabled = GeneratorBenchmarkScenarioFactory.CreateFeatureRichGraph(emitStaticExtensions: false);
m_featureRichStaticExtensionsEnabled = GeneratorBenchmarkScenarioFactory.CreateFeatureRichGraph(emitStaticExtensions: true);
m_multiAssemblyOverrideGraph = GeneratorBenchmarkScenarioFactory.CreateMultiAssemblyOverrideGraph(baseServiceCount: 128, overrideCount: 16);
m_manyAssembliesGraph = GeneratorBenchmarkScenarioFactory.CreateManyAssembliesGraph(assemblyCount: 25, typesPerAssembly: 2);
m_featureRichIncremental = GeneratorBenchmarkScenarioFactory.CreateFeatureRichIncrementalScenario();
m_referenceAssemblyIncremental = GeneratorBenchmarkScenarioFactory.CreateReferenceAssemblyIncrementalScenario();

GeneratorBenchmarkHarness.Validate(m_constructorGraph);
GeneratorBenchmarkHarness.Validate(m_constructorGraphWithStaticExtensions);
GeneratorBenchmarkHarness.Validate(m_noiseHeavyProject);
GeneratorBenchmarkHarness.Validate(m_featureRichStaticExtensionsDisabled);
GeneratorBenchmarkHarness.Validate(m_featureRichStaticExtensionsEnabled);
GeneratorBenchmarkHarness.Validate(m_multiAssemblyOverrideGraph);
GeneratorBenchmarkHarness.Validate(m_manyAssembliesGraph);
}

[Benchmark]
public int Cold_ConstructorGraph() => GeneratorBenchmarkHarness.RunCold(m_constructorGraph);

/// <summary>
/// Same 250-service linear dependency chain as <see cref="Cold_ConstructorGraph"/>, but with
/// static extensions enabled — isolates PropagateStaticExtensionRequirements's fixed-point-loop
/// cost (a hypothesized, previously-untested scaling risk for long chains) from the ordinary
/// per-injection processing cost already captured by Cold_ConstructorGraph.
/// </summary>
[Benchmark]
public int Cold_ConstructorGraph_StaticExtensionsEnabled() => GeneratorBenchmarkHarness.RunCold(m_constructorGraphWithStaticExtensions);

[Benchmark]
public int Cold_NoiseHeavyProject() => GeneratorBenchmarkHarness.RunCold(m_noiseHeavyProject);

Expand All @@ -55,6 +70,56 @@ public void Setup()

[Benchmark]
public int Cold_MultiAssemblyOverrideGraph() => GeneratorBenchmarkHarness.RunCold(m_multiAssemblyOverrideGraph);

/// <summary>
/// 25 small assemblies where each layer cumulatively references every prior layer
/// (O(n^2) reference edges), isolating <c>GetRelevantAssemblies</c>'s assembly-reachability
/// BFS/DFS cost from per-type scanning cost (already covered by <see cref="Cold_NoiseHeavyProject"/>).
/// </summary>
[Benchmark]
public int Cold_ManyAssembliesGraph() => GeneratorBenchmarkHarness.RunCold(m_manyAssembliesGraph);

/// <summary>
/// Re-runs the warmed driver against the exact same compilation it was warmed with. Floor/
/// reference point for the Incremental_* benchmarks below: since nothing at all changed, this
/// is the fastest possible incremental re-run and isolates Roslyn's own driver-level overhead
/// from any FactoryGenerator-specific recomputation.
/// </summary>
[Benchmark]
public int Incremental_NoOpRerun() => GeneratorBenchmarkHarness.RunIncremental(m_featureRichIncremental.WarmDriver, m_featureRichIncremental.BaselineCompilation);

/// <summary>
/// Re-runs the warmed driver after only <c>Utilities.cs</c> changed — a file with zero
/// injectable types, entirely unrelated to dependency injection. In a well-incrementalized
/// generator this should cost close to <see cref="Incremental_NoOpRerun"/>; if
/// FactoryGenerator.Initialize()'s direct use of context.CompilationProvider (threaded through
/// GetInjectionScanScope, and combined in again for the analysis/RegisterSourceOutput stages)
/// poisons Roslyn's per-stage caching, this should instead cost close to a full cold run.
/// </summary>
[Benchmark]
public int Incremental_UnrelatedEdit() => GeneratorBenchmarkHarness.RunIncremental(m_featureRichIncremental.WarmDriver, m_featureRichIncremental.UnrelatedEditCompilation);

/// <summary>
/// Re-runs the warmed driver after an injected constructor's parameters/defaults changed — a
/// legitimate, relevant edit that should cost something regardless of pipeline architecture.
/// </summary>
[Benchmark]
public int Incremental_InjectedSignatureEdit() => GeneratorBenchmarkHarness.RunIncremental(m_featureRichIncremental.WarmDriver, m_featureRichIncremental.InjectedSignatureEditCompilation);

/// <summary>
/// Re-runs the warmed driver after a new <c>[Inject]</c> attribute was added — another
/// legitimate, relevant edit that should cost something regardless of pipeline architecture.
/// </summary>
[Benchmark]
public int Incremental_AddInjection() => GeneratorBenchmarkHarness.RunIncremental(m_featureRichIncremental.WarmDriver, m_featureRichIncremental.AddInjectCompilation);

/// <summary>
/// Re-runs a warmed driver after a *referenced assembly's* source changed (not the current
/// compilation's own source). Exercises the metadata-symbol scanning path (GetRelevantAssemblies/
/// GetCandidateTypes over referenced assemblies) rather than the own-compilation discovery path.
/// </summary>
[Benchmark]
public int Incremental_ReferenceAssemblyChange() => GeneratorBenchmarkHarness.RunIncremental(m_referenceAssemblyIncremental.WarmDriver, m_referenceAssemblyIncremental.ChangedCompilation);
}

internal sealed class ColdGeneratorScenario(CSharpCompilation compilation, AnalyzerConfigOptionsProvider optionsProvider)
Expand Down Expand Up @@ -166,6 +231,22 @@ public static ColdGeneratorScenario CreateConstructorGraph(int serviceCount)
return new ColdGeneratorScenario(compilation, s_staticExtensionsDisabledOptions);
}

/// <summary>
/// Same long linear dependency chain as <see cref="CreateConstructorGraph"/>, but with static
/// extensions enabled. Exists to directly measure whether
/// <c>PropagateStaticExtensionRequirements</c>'s fixed-point loop (which can take one iteration
/// per hop of a dependency chain to converge) scales poorly with chain length, rather than
/// leaving that as an untested hypothesis.
/// </summary>
public static ColdGeneratorScenario CreateConstructorGraphWithStaticExtensions(int serviceCount)
{
var compilation = CreateCompilation(
"GeneratorConstructorGraphStaticExtensionsBenchmarks",
new BenchmarkSourceDocument("ConstructorGraph.cs", BuildConstructorGraphSource("GeneratorConstructorGraphStaticExtensionsInput", serviceCount)));

return new ColdGeneratorScenario(compilation, s_staticExtensionsEnabledOptions);
}

public static ColdGeneratorScenario CreateNoiseHeavyProject(int serviceCount, int noiseTypeCount)
{
var compilation = CreateCompilation(
Expand Down Expand Up @@ -205,6 +286,35 @@ public static ColdGeneratorScenario CreateMultiAssemblyOverrideGraph(int baseSer
return new ColdGeneratorScenario(derivedCompilation, s_staticExtensionsDisabledOptions);
}

/// <summary>
/// A layered graph of many small assemblies where each layer cumulatively references every
/// prior layer (fan-in), producing O(assemblyCount^2) reference edges rather than one edge per
/// assembly. Exists to measure <c>GetRelevantAssemblies</c>'s assembly-reachability BFS cost in
/// isolation, since <see cref="CreateMultiAssemblyOverrideGraph"/> only involves 2 custom
/// assemblies and can't show a signal for that specific cost.
/// </summary>
public static ColdGeneratorScenario CreateManyAssembliesGraph(int assemblyCount, int typesPerAssembly)
{
var priorReferences = ImmutableArray<MetadataReference>.Empty;
CSharpCompilation compilation = null!;
for (var i = 0; i < assemblyCount; i++)
{
var assemblyName = $"GeneratorManyAssembliesLayer{i}";
var references = priorReferences.IsDefaultOrEmpty ? s_metadataReferences : s_metadataReferences.AddRange(priorReferences);
compilation = CreateCompilation(
assemblyName,
references,
new BenchmarkSourceDocument($"Layer{i}.cs", BuildManyAssembliesLayerSource(assemblyName, i, typesPerAssembly)));

// The final layer doesn't need to be emitted; only earlier layers need a real
// MetadataReference so later layers can reference them.
if (i < assemblyCount - 1)
priorReferences = priorReferences.Add(EmitReference(compilation));
}

return new ColdGeneratorScenario(compilation, s_staticExtensionsDisabledOptions);
}

public static FeatureRichIncrementalScenario CreateFeatureRichIncrementalScenario()
{
const string assemblyName = "GeneratorFeatureRichIncremental";
Expand Down Expand Up @@ -637,6 +747,27 @@ private static string BuildOverrideDerivedSource(string baseAssemblyName, string
return sb.ToString();
}

private static string BuildManyAssembliesLayerSource(string assemblyName, int layerIndex, int typesPerLayer)
{
var sb = new StringBuilder();
sb.AppendLine("using FactoryGenerator.Attributes;");
sb.AppendLine();
sb.AppendLine($"namespace {assemblyName}");
sb.AppendLine("{");

for (var i = 0; i < typesPerLayer; i++)
{
sb.AppendLine("[Inject]");
sb.AppendLine($"public sealed class Layer{layerIndex}Service{i}");
sb.AppendLine("{");
sb.AppendLine("}");
sb.AppendLine();
}

sb.AppendLine("}");
return sb.ToString();
}

private static string BuildReferenceBaseSource(string assemblyName, bool includeSecondBasePart)
{
var secondPart = includeSecondBasePart
Expand Down
Loading
Loading