Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
28fe6da
Add implementation plan for net10.0 span-WhenAll optimization
buvinghausen May 13, 2026
40e0dd8
Add net10.0 TFM to library
buvinghausen May 13, 2026
6295ba4
Scaffold TaskTupleAwaiter.Benchmarks project
buvinghausen May 13, 2026
387e4cf
Add BenchmarkSwitcher entry point
buvinghausen May 13, 2026
56fc63c
Scope test/Directory.Build.props to TaskTupleAwaiter.Tests only
buvinghausen May 13, 2026
c3d2560
Add typed-tuple await benchmarks
buvinghausen May 13, 2026
ced6eff
Add non-generic-tuple await benchmarks
buvinghausen May 13, 2026
1585503
Add ConfigureAwait benchmarks
buvinghausen May 13, 2026
708ac4e
Document how to run the benchmarks
buvinghausen May 13, 2026
e8f887d
Capture baseline benchmark numbers (net8.0 + net10.0)
buvinghausen May 13, 2026
bf7dd88
Emit collection-expression WhenAll calls in generated extensions
buvinghausen May 13, 2026
f7404c3
Add net10.0 to AOT smoke test TFMs
buvinghausen May 13, 2026
f71b46c
Document net10.0 TFM and span-based WhenAll emission
buvinghausen May 13, 2026
1323c6d
Record post-change benchmark numbers (net8.0 + net10.0)
buvinghausen May 13, 2026
36738a1
Address final code review nits
buvinghausen May 13, 2026
3def907
Merge branch 'master' into worktree-net10-span-whenall
buvinghausen May 13, 2026
b82de8a
Fix claude puke in SLNX
buvinghausen May 14, 2026
a9babb4
End claude's msbuild nightmare
buvinghausen May 14, 2026
8a50cdf
Make AOT smoke test more robust
buvinghausen May 14, 2026
5432711
Emit awaiter types as plain readonly structs
buvinghausen May 14, 2026
7c98fd2
Inline trivial awaiter forwarders
buvinghausen May 14, 2026
0b5fcdc
Rewrite benchmark results for final shipped state
buvinghausen May 14, 2026
2bda5d9
Clarify docs: net10 TFM drives span WhenAll binding
Copilot May 14, 2026
0cb1c97
Clarify [] syntax is stylistic only in plan/spec/comments
Copilot May 14, 2026
4961e1a
Apply review wording suggestions in plan and benchmark docs
Copilot May 14, 2026
4d4ab54
Apply suggestion from @jnm2
jnm2 May 14, 2026
c8cfd60
Apply suggestions from code review
jnm2 May 14, 2026
21a63e8
Apply suggestion from @jnm2
jnm2 May 14, 2026
5a6a164
Retarget PR content from net10 to net9
Copilot May 14, 2026
6319c5f
Apply suggestion from @jnm2
jnm2 May 14, 2026
edeca49
Fix unresolved README merge markers
Copilot May 14, 2026
b692d1b
Clean up README conflict artifact and spec wording
Copilot May 14, 2026
826e42c
Apply suggestions from code review
jnm2 May 14, 2026
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
15 changes: 10 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ jobs:
dotnet-version: |
8.0.*
9.0.*
10.0.*
11.0.*
env:
DOTNET_NOLOGO: 1
Expand All @@ -26,7 +25,13 @@ jobs:
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build -v n /p:CollectCoverage=true /p:CoverletOutput='../../' /p:CoverletOutputFormat=opencover /p:Threshold=91 /p:SkipAutoProps=true /p:UseSourceLink=true
- name: AOT smoke-test (net8.0)
run: dotnet publish test/TaskTupleAwaiter.AotSmokeTest -c Release -f net8.0 --no-restore
- name: AOT smoke-test (net11.0)
run: dotnet publish test/TaskTupleAwaiter.AotSmokeTest -c Release -f net11.0 --no-restore
- name: AOT smoke test (net8.0)
run: |
dotnet publish tests/smoke/TaskTupleAwaiter.AotSmokeTest -c Release -f net8.0 -r win-x64 -o aot-out --no-restore
.\aot-out\TaskTupleAwaiter.AotSmokeTest.exe
shell: pwsh
- name: AOT smoke test (net11.0)
run: |
dotnet publish tests/smoke/TaskTupleAwaiter.AotSmokeTest -c Release -f net11.0 -r win-x64 -o aot-out --no-restore
.\aot-out\TaskTupleAwaiter.AotSmokeTest.exe
shell: pwsh
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,13 @@ coverage.*.info
coverage.*.json
coverage.*.xml


# BenchmarkDotNet output
BenchmarkDotNet.Artifacts/

# Local `dotnet pack` artifacts (we publish from CI, not from working copies)
*.nupkg
*.snupkg

# Claude worktree fiesta
.claude/
18 changes: 13 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ TaskTupleAwaiter provides extension methods that allow you to `await` a `ValueTu
```
TaskTupleAwaiter/
├── src/
│ ├── TaskTupleAwaiter/ # Main library shell (netstandard2.0, net462, net8.0)
│ ├── TaskTupleAwaiter/ # Main library shell (netstandard2.0, net462, net8.0, net9.0)
│ │ # No hand-authored .cs sources — code is generated at build and compiled into the library.
│ └── TaskTupleAwaiter.Generator/ # Roslyn incremental source generator (netstandard2.0)
│ └── TaskTupleExtensionsGenerator.cs
Expand All @@ -22,9 +22,16 @@ TaskTupleAwaiter/
│ │ ├── DummyException.cs
│ │ ├── On.cs
│ │ └── SpySynchronizationContext.cs
│ └── TaskTupleAwaiter.AotSmokeTest/ # NativeAOT downstream-consumer smoke-test (net8.0, net11.0)
│ ├── TaskTupleAwaiter.AotSmokeTest.csproj
│ └── Program.cs
│ ├── TaskTupleAwaiter.AotSmokeTest/ # NativeAOT downstream-consumer smoke-test (net8.0, net9.0, net11.0)
│ │ ├── TaskTupleAwaiter.AotSmokeTest.csproj
│ │ └── Program.cs
│ └── TaskTupleAwaiter.Benchmarks/ # BenchmarkDotNet harness (net8.0, net9.0)
│ ├── TaskTupleAwaiter.Benchmarks.csproj # xUnit/Shouldly inheritance from test/Directory.Build.props is bypassed via the MSBuildProjectName condition there, not via a local Directory.Build.props.
│ ├── Program.cs # BenchmarkSwitcher entry point.
│ ├── TypedTupleAwaitBenchmarks.cs
│ ├── NonGenericTupleAwaitBenchmarks.cs
│ ├── ConfigureAwaitBenchmarks.cs
│ └── README.md # How to run; the runs are local-only, not CI.
├── docs/superpowers/ # Specs and implementation plans
├── README.md
├── LICENSE.txt
Expand All @@ -36,7 +43,7 @@ TaskTupleAwaiter/
| Concern | Choice |
|---|---|
| Language | C# 14.0 |
| Library TFMs | netstandard2.0, net462, net8.0 |
| Library TFMs | netstandard2.0, net462, net8.0, net9.0 |
| Generator target | netstandard2.0 (Roslyn analyzer requirement) |
| AOT-compatible TFMs | net8.0+ (`<IsAotCompatible>true</IsAotCompatible>` via `IsTargetFrameworkCompatible`) |
| Generator framework | Roslyn `IIncrementalGenerator` |
Expand All @@ -49,6 +56,7 @@ TaskTupleAwaiter/
### Source Generator (`TaskTupleExtensionsGenerator`)
- Implements `IIncrementalGenerator` (not the older `ISourceGenerator`).
- **Feature-detects** `ConfigureAwaitOptions` at compile time by resolving the type `System.Threading.Tasks.ConfigureAwaitOptions` from the target compilation — **do not use** `#if NET8_0_OR_GREATER` or preprocessor symbols.
- Emits `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])`. Overload binding is determined by the library TFM: `netstandard2.0` / `net462` / `net8.0` bind to `Task.WhenAll(params Task[])` (heap-allocated array — same IL as before), while `net9.0`+ binds to `Task.WhenAll(ReadOnlySpan<Task>)` and stack-allocates the buffer. No runtime feature detection needed for this — overload preference is purely a compiler/TFM concern.
- Emits a single file `TaskTupleExtensions.g.cs` into the `System.Threading.Tasks` namespace (suppressing `IDE0130`).
- Arity-1 typed tuples (`ValueTuple<Task<T1>>`) delegate directly to the inner task's awaiter — no custom awaiter struct is generated.
- Arities 2–16 emit `TupleTaskAwaiter<T1,...,TN>` and `TupleConfiguredTaskAwaitable<T1,...,TN>` `readonly record struct` types per arity.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ var (user, orders) = await (GetUserAsync(id), GetOrdersAsync(id));
- **`ConfigureAwait` support** — works with `ConfigureAwait(false)` and .NET 8+ `ConfigureAwaitOptions`
- **Non-generic `Task` support** — await tuples of `Task` (not just `Task<T>`) when you don't need return values
- **Zero dependencies** — a single file, no external packages (except `System.ValueTuple` on .NET Framework 4.6.2)
- **Broad compatibility** — targets .NET Standard 2.0, .NET Framework 4.6.2, and .NET 8+
- **Broad compatibility** — supports .NET Standard 2.0+, .NET Framework 4.6.2+, and .NET 8+
- **NativeAOT ready** — the package sets `<IsAotCompatible>true</IsAotCompatible>` for .NET 8+ targets, and CI publishes downstream NativeAOT smoke tests

## Installation
Expand Down
27 changes: 11 additions & 16 deletions TaskTupleAwaiter.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path=".gitattributes" />
<File Path=".github\workflows\ci.yml" />
<File Path=".github\workflows\release.yml" />
<File Path=".github/workflows/ci.yml" />
<File Path=".github/workflows/release.yml" />
<File Path=".gitignore" />
<File Path="CLAUDE.md" />
<File Path="CODE_OF_CONDUCT.md" />
Expand All @@ -13,19 +13,14 @@
<File Path="README.md" />
</Folder>
<Folder Name="/Solution Items/src/">
<File Path="src\Directory.Build.props" />
<File Path="src/Directory.Build.props" />
</Folder>
<Folder Name="/Solution Items/test/">
<File Path="test\Directory.Build.props" />
<Folder Name="/Solution Items/tests/unit/">
<File Path="tests/unit/Directory.Build.props" />
</Folder>
<Project Path="src\TaskTupleAwaiter\TaskTupleAwaiter.csproj" />
<Folder Name="/src/" />
<Folder Name="/test/">
<Project Path="test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj" />
</Folder>
<Project Path="src\TaskTupleAwaiter.Generator\TaskTupleAwaiter.Generator.csproj" />
<Project Path="test\TaskTupleAwaiter.Tests\TaskTupleAwaiter.Tests.csproj">
<Configuration Solution="Release|*" Project="*|*|NoBuild" />
</Project>
<Project Path="test\TaskTupleAwaiter.AotSmokeTest\TaskTupleAwaiter.AotSmokeTest.csproj" />
</Solution>
<Project Path="benches/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj" />
<Project Path="src/TaskTupleAwaiter.Generator/TaskTupleAwaiter.Generator.csproj" />
<Project Path="src/TaskTupleAwaiter/TaskTupleAwaiter.csproj" />
<Project Path="tests/smoke/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj" />
<Project Path="tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj" />
</Solution>
52 changes: 52 additions & 0 deletions benches/TaskTupleAwaiter.Benchmarks/ConfigureAwaitBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using BenchmarkDotNet.Attributes;

namespace TaskTupleAwaiter.Benchmarks;

[MemoryDiagnoser]
public class ConfigureAwaitBenchmarks
{
[Benchmark]
public async Task Typed_Arity4_Bool_False() =>
_ = await (
Task.FromResult(1), Task.FromResult(2),
Task.FromResult(3), Task.FromResult(4)).ConfigureAwait(false);

[Benchmark]
public async Task Typed_Arity4_Options_None() =>
_ = await (
Task.FromResult(1), Task.FromResult(2),
Task.FromResult(3), Task.FromResult(4)).ConfigureAwait(ConfigureAwaitOptions.None);

[Benchmark]
public async Task Typed_Arity16_Bool_False() =>
_ = await (
Task.FromResult(1), Task.FromResult(2), Task.FromResult(3), Task.FromResult(4),
Task.FromResult(5), Task.FromResult(6), Task.FromResult(7), Task.FromResult(8),
Task.FromResult(9), Task.FromResult(10), Task.FromResult(11), Task.FromResult(12),
Task.FromResult(13), Task.FromResult(14), Task.FromResult(15), Task.FromResult(16)
).ConfigureAwait(false);

[Benchmark]
public async Task Typed_Arity16_Options_None() =>
_ = await (
Task.FromResult(1), Task.FromResult(2), Task.FromResult(3), Task.FromResult(4),
Task.FromResult(5), Task.FromResult(6), Task.FromResult(7), Task.FromResult(8),
Task.FromResult(9), Task.FromResult(10), Task.FromResult(11), Task.FromResult(12),
Task.FromResult(13), Task.FromResult(14), Task.FromResult(15), Task.FromResult(16)
).ConfigureAwait(ConfigureAwaitOptions.None);

[Benchmark]
public async Task NonGeneric_Arity4_Bool_False() =>
await (
(Task)Task.CompletedTask, Task.CompletedTask,
Task.CompletedTask, Task.CompletedTask).ConfigureAwait(false);

[Benchmark]
public async Task NonGeneric_Arity16_Options_None() =>
await (
(Task)Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask
).ConfigureAwait(ConfigureAwaitOptions.None);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using BenchmarkDotNet.Attributes;

namespace TaskTupleAwaiter.Benchmarks;

[MemoryDiagnoser]
public class NonGenericTupleAwaitBenchmarks
{
[Benchmark]
public async Task Arity2_PreCompleted() =>
await ((Task)Task.CompletedTask, Task.CompletedTask);

[Benchmark]
public async Task Arity4_PreCompleted() =>
await (
(Task)Task.CompletedTask, Task.CompletedTask,
Task.CompletedTask, Task.CompletedTask);

[Benchmark]
public async Task Arity8_PreCompleted() =>
await (
(Task)Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask);

[Benchmark]
public async Task Arity16_PreCompleted() =>
await (
(Task)Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask);

[Benchmark]
public async Task Arity2_Async() =>
await (YieldAsync(), YieldAsync());

[Benchmark]
public async Task Arity4_Async() =>
await (YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync());

[Benchmark]
public async Task Arity8_Async() =>
await (
YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync(),
YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync());

[Benchmark]
public async Task Arity16_Async() =>
await (
YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync(),
YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync(),
YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync(),
YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync());

static async Task YieldAsync() => await Task.Yield();
}
3 changes: 3 additions & 0 deletions benches/TaskTupleAwaiter.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
41 changes: 41 additions & 0 deletions benches/TaskTupleAwaiter.Benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# TaskTupleAwaiter.Benchmarks

BenchmarkDotNet harness measuring the allocation and time profile of awaiting `ValueTuple` of `Task` / `Task<T>` across:

- Arities 2, 4, 8, 16
- Typed (`Task<T>`) and non-generic (`Task`) tuples
- Pre-completed (`Task.FromResult`) and async (`Task.Yield`) completion modes
- `ConfigureAwait(bool)` and `ConfigureAwait(ConfigureAwaitOptions)` paths

The point of these benchmarks is to compare allocation profiles between `net8.0` (where the generated `Task.WhenAll(...)` call binds to `params Task[]` and heap-allocates the array) and `net9.0` (where compiling the library for `net9.0` binds to `Task.WhenAll(ReadOnlySpan<Task>)` and stack-allocates).

## Running

Run all benchmarks on net9.0:

```sh
dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0
```

Run all benchmarks on net8.0:

```sh
dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net8.0
```

Filter to one class:

```sh
dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*TypedTupleAwaitBenchmarks*"
```

## Expected outcome

With the `net9.0` library target in place:

- **net8.0:** allocations and timing unchanged from baseline. Same IL as today.
- **net9.0:** `Allocated` per op drops by approximately `24 + 8·N` bytes (the `Task[N]` array we no longer allocate). Mean time per op is flat or slightly improved due to reduced GC pressure.

## Not run in CI

Benchmark runs are slow (multi-minute) and have enough run-to-run variance that they are unsuitable for CI gating. They are a manual local check before tagging a release or when validating perf-sensitive changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<!-- BDN convention requires underscore-separated benchmark method names and instance methods. -->
<NoWarn>$(NoWarn);CA1707;CA1822;CS1591</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="*" />
<ProjectReference Include="..\..\src\TaskTupleAwaiter\TaskTupleAwaiter.csproj" />
</ItemGroup>

</Project>
57 changes: 57 additions & 0 deletions benches/TaskTupleAwaiter.Benchmarks/TypedTupleAwaitBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using BenchmarkDotNet.Attributes;

namespace TaskTupleAwaiter.Benchmarks;

[MemoryDiagnoser]
public class TypedTupleAwaitBenchmarks
{
[Benchmark]
public async Task Arity2_PreCompleted() =>
_ = await (Task.FromResult(1), Task.FromResult(2));

[Benchmark]
public async Task Arity4_PreCompleted() =>
_ = await (Task.FromResult(1), Task.FromResult(2), Task.FromResult(3), Task.FromResult(4));

[Benchmark]
public async Task Arity8_PreCompleted() =>
_ = await (
Task.FromResult(1), Task.FromResult(2), Task.FromResult(3), Task.FromResult(4),
Task.FromResult(5), Task.FromResult(6), Task.FromResult(7), Task.FromResult(8));

[Benchmark]
public async Task Arity16_PreCompleted() =>
_ = await (
Task.FromResult(1), Task.FromResult(2), Task.FromResult(3), Task.FromResult(4),
Task.FromResult(5), Task.FromResult(6), Task.FromResult(7), Task.FromResult(8),
Task.FromResult(9), Task.FromResult(10), Task.FromResult(11), Task.FromResult(12),
Task.FromResult(13), Task.FromResult(14), Task.FromResult(15), Task.FromResult(16));

[Benchmark]
public async Task Arity2_Async() =>
_ = await (YieldAsync(1), YieldAsync(2));

[Benchmark]
public async Task Arity4_Async() =>
_ = await (YieldAsync(1), YieldAsync(2), YieldAsync(3), YieldAsync(4));

[Benchmark]
public async Task Arity8_Async() =>
_ = await (
YieldAsync(1), YieldAsync(2), YieldAsync(3), YieldAsync(4),
YieldAsync(5), YieldAsync(6), YieldAsync(7), YieldAsync(8));

[Benchmark]
public async Task Arity16_Async() =>
_ = await (
YieldAsync(1), YieldAsync(2), YieldAsync(3), YieldAsync(4),
YieldAsync(5), YieldAsync(6), YieldAsync(7), YieldAsync(8),
YieldAsync(9), YieldAsync(10), YieldAsync(11), YieldAsync(12),
YieldAsync(13), YieldAsync(14), YieldAsync(15), YieldAsync(16));

static async Task<int> YieldAsync(int value)
{
await Task.Yield();
return value;
}
}
Loading
Loading