Skip to content

Commit 882e625

Browse files
authored
Add async enumerable benchmark support (#3124)
* Add `IAsyncEnumerable` benchmark support. * Add validator and analyzer rules.
1 parent 8ab56fe commit 882e625

45 files changed

Lines changed: 3513 additions & 477 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
uid: docs.async-enumerable
3+
name: IAsyncEnumerable Benchmarks
4+
---
5+
6+
# IAsyncEnumerable Benchmarks
7+
8+
Benchmark methods can return `IAsyncEnumerable<T>` (or any type that satisfies the C# `await foreach` pattern). The framework drives the iteration to completion on every benchmark invocation, the same way a `await foreach` consumer would, and the elapsed time covers everything from the factory call through the final `MoveNextAsync` that observes end-of-stream.
9+
10+
```csharp
11+
public class AsyncEnumerableBenchmarks
12+
{
13+
[Benchmark]
14+
public async IAsyncEnumerable<int> Produce()
15+
{
16+
for (int i = 0; i < 1000; i++)
17+
{
18+
await Task.Yield();
19+
yield return i;
20+
}
21+
}
22+
}
23+
```
24+
25+
## What gets measured
26+
27+
For each invocation, the framework:
28+
29+
1. Calls the workload method to obtain the enumerable (compiler-generated state machines do no work here — the body runs lazily).
30+
2. Calls `GetAsyncEnumerator()` and drives `MoveNextAsync()` / `Current` until the stream ends.
31+
32+
Disposal (`DisposeAsync`) is invoked when the enumerator implements `IAsyncDisposable` or exposes a public `DisposeAsync()` matching the C# pattern, mirroring `await foreach` semantics.
33+
34+
## Custom enumerable types
35+
36+
`IsAsyncEnumerable` detection mirrors the C# `await foreach` resolution rules: it accepts any type that **is** `IAsyncEnumerable<T>`, has a public `GetAsyncEnumerator` method matching the pattern (with all parameters optional, including `CancellationToken`), or implements the interface explicitly. The element type comes from `Current` so it tracks what the compiler binds to — even when a type defines a public pattern method whose `Current` differs from the explicitly-implemented `IAsyncEnumerable<U>.GetAsyncEnumerator`'s `U`. This means hand-written enumerables, ref-struct enumerables, and `ConfiguredCancelableAsyncEnumerable<T>` all work without special configuration.
37+
38+
## Cancellation
39+
40+
The framework does not implicitly call `.WithCancellation(token)` on the returned enumerable. Two reasons:
41+
42+
1. It would make the no-cancellation path unmeasurable.
43+
2. For types that implement `IAsyncEnumerable<T>` *and* define a public pattern `GetAsyncEnumerator`, `WithCancellation` flips dispatch from the pattern to the interface — silently changing what gets measured.
44+
45+
You have two opt-in routes:
46+
47+
**Class-level field** — works for any benchmark, no return-type changes needed. The iterator can read the token directly:
48+
49+
```csharp
50+
public class CancellableProducer
51+
{
52+
[BenchmarkCancellation]
53+
public CancellationToken CancellationToken;
54+
55+
[Benchmark]
56+
public async IAsyncEnumerable<int> Produce()
57+
{
58+
for (int i = 0; i < 1000; i++)
59+
{
60+
CancellationToken.ThrowIfCancellationRequested();
61+
await Task.Yield();
62+
yield return i;
63+
}
64+
}
65+
}
66+
```
67+
68+
**`[EnumeratorCancellation]` propagation** — return `ConfiguredCancelableAsyncEnumerable<T>` directly from the benchmark to opt the iterator into the C#-idiomatic pattern. The framework's `await foreach` over the returned configured enumerable will propagate the token to a parameter marked `[EnumeratorCancellation]` inside your iterator:
69+
70+
```csharp
71+
public class EnumeratorCancellationProducer
72+
{
73+
[BenchmarkCancellation]
74+
public CancellationToken CancellationToken;
75+
76+
[Benchmark]
77+
public ConfiguredCancelableAsyncEnumerable<int> Produce()
78+
=> Inner().WithCancellation(CancellationToken);
79+
80+
private static async IAsyncEnumerable<int> Inner([EnumeratorCancellation] CancellationToken ct = default)
81+
{
82+
for (int i = 0; i < 1000; i++)
83+
{
84+
ct.ThrowIfCancellationRequested();
85+
await Task.Yield();
86+
yield return i;
87+
}
88+
}
89+
}
90+
```
91+
92+
## Setup and cleanup
93+
94+
`[GlobalSetup]`, `[GlobalCleanup]`, `[IterationSetup]`, and `[IterationCleanup]` methods may **not** return `IAsyncEnumerable<T>`. The framework only awaits awaitables in those positions; it would call the iterator factory and discard the enumerable without ever running the body. A mandatory validator rejects this at startup with a clear error — return `void`, `Task`, `ValueTask`, or any awaitable type instead.
95+
96+
## DisassemblyDiagnoser
97+
98+
`[DisassemblyDiagnoser]` walks the benchmark's call graph from a synthetic entry method that just invokes the workload and discards the result. For `IAsyncEnumerable<T>` benchmarks that means the iteration body — `GetAsyncEnumerator`, `MoveNextAsync`, `Current` — is never reached from the entry, regardless of whether the return type is the interface or a custom struct/sealed type. Compiler-generated async iterators only kick off their state machine on the first `MoveNextAsync`, so the disassembler sees nothing of substance from the workload call alone.
99+
100+
To inspect the iterator body, use the diagnoser's `filters` parameter:
101+
102+
```csharp
103+
[DisassemblyDiagnoser(filters: ["*MoveNextAsync*"])]
104+
public class MyBenchmarks { /* ... */ }
105+
```

docs/articles/features/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
href: baselines.md
55
- name: Setup And Cleanup
66
href: setup-and-cleanup.md
7+
- name: IAsyncEnumerable Benchmarks
8+
href: async-enumerable.md
79
- name: Statistics
810
href: statistics.md
911
- name: Disassembler

src/BenchmarkDotNet.Analyzers/AnalyzerReleases.Unshipped.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ BDN1602 | Usage | Error | Properties annotated with [BenchmarkCancellatio
99
BDN1603 | Usage | Error | [BenchmarkCancellation] attribute is not valid on readonly fields
1010
BDN1604 | Usage | Error | Properties annotated with [BenchmarkCancellation] must have a public setter
1111
BDN1605 | Usage | Info | Async benchmarks should have a [BenchmarkCancellation] property for cancellation support
12+
BDN1700 | Usage | Error | [GlobalSetup]/[GlobalCleanup]/[IterationSetup]/[IterationCleanup] method must not return an async enumerable
13+
BDN1701 | Usage | Warning | Benchmark/setup/cleanup return type is both awaitable and an async enumerable; the iterator is never enumerated
1214

1315

1416
### Removed Rules
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using Microsoft.CodeAnalysis;
2+
3+
namespace BenchmarkDotNet.Analyzers;
4+
5+
/// <summary>
6+
/// Static-analysis counterparts to <c>ReflectionExtensions.IsAsyncEnumerable</c> and
7+
/// <c>ReflectionExtensions.IsAwaitable</c> on the runtime side. Used by analyzers that need to mirror
8+
/// the framework's `await foreach` / `await` binding shape rules at compile time.
9+
/// </summary>
10+
internal static class AsyncTypeShapes
11+
{
12+
/// <summary>
13+
/// Returns true when <paramref name="type"/> would bind as an async enumerable under the C# compiler's
14+
/// `await foreach` rules: exact <c>IAsyncEnumerable&lt;T&gt;</c> short-circuit → public-instance
15+
/// <c>GetAsyncEnumerator</c> pattern with all-optional parameters whose return has public-instance
16+
/// <c>MoveNextAsync</c> (all-optional params) and a public <c>Current</c> property → interface fallback
17+
/// via <see cref="ITypeSymbol.AllInterfaces"/>.
18+
/// </summary>
19+
public static bool IsAsyncEnumerable(ITypeSymbol type, INamedTypeSymbol? asyncEnumerableInterfaceSymbol)
20+
{
21+
if (asyncEnumerableInterfaceSymbol != null
22+
&& type is INamedTypeSymbol named
23+
&& SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, asyncEnumerableInterfaceSymbol))
24+
{
25+
return true;
26+
}
27+
28+
if (TryFindPatternGetAsyncEnumerator(type) is { } enumeratorType
29+
&& HasPatternMoveNextAsync(enumeratorType)
30+
&& HasPublicInstanceProperty(enumeratorType, "Current"))
31+
{
32+
return true;
33+
}
34+
35+
if (asyncEnumerableInterfaceSymbol != null)
36+
{
37+
foreach (var implemented in type.AllInterfaces)
38+
{
39+
if (SymbolEqualityComparer.Default.Equals(implemented.OriginalDefinition, asyncEnumerableInterfaceSymbol))
40+
{
41+
return true;
42+
}
43+
}
44+
}
45+
46+
return false;
47+
}
48+
49+
/// <summary>
50+
/// Returns true when <paramref name="type"/> exposes a public parameterless <c>GetAwaiter</c> method —
51+
/// the necessary precondition for the C# compiler's <c>await</c> binding. The analyzer doesn't drill
52+
/// into the awaiter's <c>IsCompleted</c>/<c>GetResult</c>/<c>OnCompleted</c> shape; the framework's
53+
/// runtime <c>IsAwaitable</c> check does that more thoroughly when needed.
54+
/// </summary>
55+
public static bool IsAwaitable(ITypeSymbol type)
56+
{
57+
foreach (var member in type.GetMembers("GetAwaiter"))
58+
{
59+
if (member is IMethodSymbol { DeclaredAccessibility: Accessibility.Public, IsStatic: false, Parameters.Length: 0 })
60+
{
61+
return true;
62+
}
63+
}
64+
return false;
65+
}
66+
67+
private static ITypeSymbol? TryFindPatternGetAsyncEnumerator(ITypeSymbol type)
68+
{
69+
foreach (var member in type.GetMembers("GetAsyncEnumerator"))
70+
{
71+
if (member is IMethodSymbol { DeclaredAccessibility: Accessibility.Public, IsStatic: false } method
72+
&& AllParametersOptional(method))
73+
{
74+
return method.ReturnType;
75+
}
76+
}
77+
return null;
78+
}
79+
80+
private static bool HasPatternMoveNextAsync(ITypeSymbol enumeratorType)
81+
{
82+
foreach (var member in enumeratorType.GetMembers("MoveNextAsync"))
83+
{
84+
if (member is IMethodSymbol { DeclaredAccessibility: Accessibility.Public, IsStatic: false } method
85+
&& AllParametersOptional(method))
86+
{
87+
return true;
88+
}
89+
}
90+
return false;
91+
}
92+
93+
private static bool HasPublicInstanceProperty(ITypeSymbol type, string name)
94+
{
95+
foreach (var member in type.GetMembers(name))
96+
{
97+
if (member is IPropertySymbol { DeclaredAccessibility: Accessibility.Public, IsStatic: false })
98+
{
99+
return true;
100+
}
101+
}
102+
return false;
103+
}
104+
105+
private static bool AllParametersOptional(IMethodSymbol method)
106+
{
107+
foreach (var parameter in method.Parameters)
108+
{
109+
if (!parameter.IsOptional)
110+
{
111+
return false;
112+
}
113+
}
114+
return true;
115+
}
116+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using Microsoft.CodeAnalysis.CSharp.Syntax;
4+
using Microsoft.CodeAnalysis.Diagnostics;
5+
using System.Collections.Immutable;
6+
7+
namespace BenchmarkDotNet.Analyzers.Attributes;
8+
9+
/// <summary>
10+
/// Static counterpart to <c>SetupCleanupValidator.ValidateReturnType</c>: a method annotated with
11+
/// <c>[GlobalSetup]</c>, <c>[GlobalCleanup]</c>, <c>[IterationSetup]</c>, or <c>[IterationCleanup]</c>
12+
/// must not return an async enumerable. BenchmarkDotNet awaits awaitable returns from those methods but
13+
/// does not enumerate async enumerables, so the iterator body would silently never run.
14+
/// </summary>
15+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
16+
public class SetupCleanupAsyncEnumerableAnalyzer : DiagnosticAnalyzer
17+
{
18+
internal static readonly DiagnosticDescriptor MustNotReturnAsyncEnumerableRule = new(
19+
DiagnosticIds.Attributes_SetupCleanup_MustNotReturnAsyncEnumerable,
20+
AnalyzerHelper.GetResourceString(nameof(BenchmarkDotNetAnalyzerResources.Attributes_SetupCleanup_MustNotReturnAsyncEnumerable_Title)),
21+
AnalyzerHelper.GetResourceString(nameof(BenchmarkDotNetAnalyzerResources.Attributes_SetupCleanup_MustNotReturnAsyncEnumerable_MessageFormat)),
22+
"Usage",
23+
DiagnosticSeverity.Error,
24+
isEnabledByDefault: true,
25+
description: AnalyzerHelper.GetResourceString(nameof(BenchmarkDotNetAnalyzerResources.Attributes_SetupCleanup_MustNotReturnAsyncEnumerable_Description)));
26+
27+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => new DiagnosticDescriptor[]
28+
{
29+
MustNotReturnAsyncEnumerableRule,
30+
}.ToImmutableArray();
31+
32+
private static readonly string[] SetupCleanupAttributeMetadataNames =
33+
[
34+
"BenchmarkDotNet.Attributes.GlobalSetupAttribute",
35+
"BenchmarkDotNet.Attributes.GlobalCleanupAttribute",
36+
"BenchmarkDotNet.Attributes.IterationSetupAttribute",
37+
"BenchmarkDotNet.Attributes.IterationCleanupAttribute",
38+
];
39+
40+
public override void Initialize(AnalysisContext analysisContext)
41+
{
42+
analysisContext.EnableConcurrentExecution();
43+
analysisContext.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
44+
45+
analysisContext.RegisterCompilationStartAction(ctx =>
46+
{
47+
// Only run if BenchmarkDotNet.Annotations is referenced.
48+
if (AnalyzerHelper.GetBenchmarkAttributeTypeSymbol(ctx.Compilation) == null)
49+
{
50+
return;
51+
}
52+
53+
var attributeSymbols = ImmutableArray.CreateBuilder<INamedTypeSymbol>(SetupCleanupAttributeMetadataNames.Length);
54+
foreach (var metadataName in SetupCleanupAttributeMetadataNames)
55+
{
56+
var symbol = ctx.Compilation.GetTypeByMetadataName(metadataName);
57+
if (symbol != null)
58+
{
59+
attributeSymbols.Add(symbol);
60+
}
61+
}
62+
63+
if (attributeSymbols.Count == 0)
64+
{
65+
return;
66+
}
67+
68+
var asyncEnumerableInterfaceSymbol = ctx.Compilation.GetTypeByMetadataName("System.Collections.Generic.IAsyncEnumerable`1");
69+
var captured = (attributeSymbols.ToImmutable(), asyncEnumerableInterfaceSymbol);
70+
ctx.RegisterSyntaxNodeAction(c => AnalyzeMethod(c, captured), SyntaxKind.MethodDeclaration);
71+
});
72+
}
73+
74+
private static void AnalyzeMethod(
75+
SyntaxNodeAnalysisContext context,
76+
(ImmutableArray<INamedTypeSymbol> AttributeSymbols, INamedTypeSymbol? AsyncEnumerableInterfaceSymbol) captured)
77+
{
78+
if (context.Node is not MethodDeclarationSyntax methodDeclarationSyntax)
79+
{
80+
return;
81+
}
82+
83+
if (context.SemanticModel.GetDeclaredSymbol(methodDeclarationSyntax) is not IMethodSymbol methodSymbol)
84+
{
85+
return;
86+
}
87+
88+
// Find which (if any) setup/cleanup attribute is applied.
89+
INamedTypeSymbol? matchedAttribute = null;
90+
foreach (var attributeData in methodSymbol.GetAttributes())
91+
{
92+
foreach (var candidate in captured.AttributeSymbols)
93+
{
94+
if (SymbolEqualityComparer.Default.Equals(attributeData.AttributeClass, candidate))
95+
{
96+
matchedAttribute = candidate;
97+
break;
98+
}
99+
}
100+
if (matchedAttribute != null) break;
101+
}
102+
103+
if (matchedAttribute == null)
104+
{
105+
return;
106+
}
107+
108+
// Mirrors ReflectionExtensions.IsAsyncEnumerable: exact IAsyncEnumerable<T> short-circuit, then
109+
// public-instance GetAsyncEnumerator pattern, then interface fallback. The runtime validator
110+
// also explicitly skips awaitable types — if the return type happens to be both awaitable AND
111+
// an async enumerable, BenchmarkDotNet awaits it instead of rejecting it (BDN1701 covers that
112+
// ambiguity separately).
113+
var returnType = methodSymbol.ReturnType;
114+
if (!AsyncTypeShapes.IsAsyncEnumerable(returnType, captured.AsyncEnumerableInterfaceSymbol))
115+
{
116+
return;
117+
}
118+
119+
if (AsyncTypeShapes.IsAwaitable(returnType))
120+
{
121+
return;
122+
}
123+
124+
var attributeShortName = matchedAttribute.Name.EndsWith("Attribute")
125+
? matchedAttribute.Name.Substring(0, matchedAttribute.Name.Length - "Attribute".Length)
126+
: matchedAttribute.Name;
127+
128+
context.ReportDiagnostic(Diagnostic.Create(
129+
MustNotReturnAsyncEnumerableRule,
130+
methodDeclarationSyntax.ReturnType.GetLocation(),
131+
attributeShortName,
132+
methodSymbol.Name));
133+
}
134+
135+
}

0 commit comments

Comments
 (0)