diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe6264a..17e4461 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,6 @@ jobs: dotnet-version: | 8.0.* 9.0.* - 10.0.* 11.0.* env: DOTNET_NOLOGO: 1 @@ -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 diff --git a/.gitignore b/.gitignore index 10d0b66..b65666d 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index 3a1b6b5..4fc8992 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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+ (`true` via `IsTargetFrameworkCompatible`) | | Generator framework | Roslyn `IIncrementalGenerator` | @@ -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)` 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>`) delegate directly to the inner task's awaiter — no custom awaiter struct is generated. - Arities 2–16 emit `TupleTaskAwaiter` and `TupleConfiguredTaskAwaitable` `readonly record struct` types per arity. diff --git a/README.md b/README.md index 20f7296..e4dcd4e 100644 --- a/README.md +++ b/README.md @@ -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`) 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 `true` for .NET 8+ targets, and CI publishes downstream NativeAOT smoke tests ## Installation diff --git a/TaskTupleAwaiter.slnx b/TaskTupleAwaiter.slnx index 1e55bc5..358ecf8 100644 --- a/TaskTupleAwaiter.slnx +++ b/TaskTupleAwaiter.slnx @@ -2,8 +2,8 @@ - - + + @@ -13,19 +13,14 @@ - + - - + + - - - - - - - - - - - \ No newline at end of file + + + + + + diff --git a/benches/TaskTupleAwaiter.Benchmarks/ConfigureAwaitBenchmarks.cs b/benches/TaskTupleAwaiter.Benchmarks/ConfigureAwaitBenchmarks.cs new file mode 100644 index 0000000..fadb669 --- /dev/null +++ b/benches/TaskTupleAwaiter.Benchmarks/ConfigureAwaitBenchmarks.cs @@ -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); +} diff --git a/benches/TaskTupleAwaiter.Benchmarks/NonGenericTupleAwaitBenchmarks.cs b/benches/TaskTupleAwaiter.Benchmarks/NonGenericTupleAwaitBenchmarks.cs new file mode 100644 index 0000000..664ddb1 --- /dev/null +++ b/benches/TaskTupleAwaiter.Benchmarks/NonGenericTupleAwaitBenchmarks.cs @@ -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(); +} diff --git a/benches/TaskTupleAwaiter.Benchmarks/Program.cs b/benches/TaskTupleAwaiter.Benchmarks/Program.cs new file mode 100644 index 0000000..c9a0467 --- /dev/null +++ b/benches/TaskTupleAwaiter.Benchmarks/Program.cs @@ -0,0 +1,3 @@ +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/benches/TaskTupleAwaiter.Benchmarks/README.md b/benches/TaskTupleAwaiter.Benchmarks/README.md new file mode 100644 index 0000000..68c1b03 --- /dev/null +++ b/benches/TaskTupleAwaiter.Benchmarks/README.md @@ -0,0 +1,41 @@ +# TaskTupleAwaiter.Benchmarks + +BenchmarkDotNet harness measuring the allocation and time profile of awaiting `ValueTuple` of `Task` / `Task` across: + +- Arities 2, 4, 8, 16 +- Typed (`Task`) 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)` 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. diff --git a/benches/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj b/benches/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj new file mode 100644 index 0000000..f143145 --- /dev/null +++ b/benches/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj @@ -0,0 +1,16 @@ + + + + false + Exe + net8.0;net9.0 + + $(NoWarn);CA1707;CA1822;CS1591 + + + + + + + + diff --git a/benches/TaskTupleAwaiter.Benchmarks/TypedTupleAwaitBenchmarks.cs b/benches/TaskTupleAwaiter.Benchmarks/TypedTupleAwaitBenchmarks.cs new file mode 100644 index 0000000..26f73e8 --- /dev/null +++ b/benches/TaskTupleAwaiter.Benchmarks/TypedTupleAwaitBenchmarks.cs @@ -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 YieldAsync(int value) + { + await Task.Yield(); + return value; + } +} diff --git a/docs/superpowers/plans/2026-05-13-net10-span-whenall.md b/docs/superpowers/plans/2026-05-13-net9-span-whenall.md similarity index 78% rename from docs/superpowers/plans/2026-05-13-net10-span-whenall.md rename to docs/superpowers/plans/2026-05-13-net9-span-whenall.md index 709ff0a..149ef61 100644 --- a/docs/superpowers/plans/2026-05-13-net10-span-whenall.md +++ b/docs/superpowers/plans/2026-05-13-net9-span-whenall.md @@ -1,14 +1,14 @@ -# net10.0 Span-Based WhenAll + BenchmarkDotNet Implementation Plan +# net9.0 Span-Based WhenAll + BenchmarkDotNet Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Cut one `Task[]` heap allocation per `await` of a task tuple for .NET 10+ consumers by adding a `net10.0` TFM and changing the source generator to emit collection-expression `WhenAll` calls; add a BenchmarkDotNet project to measure the win. +**Goal:** Cut one `Task[]` heap allocation per `await` of a task tuple for .NET 9+ consumers by adding a `net9.0` TFM so that the span-based `Task.WhenAll` overload is used; add a BenchmarkDotNet project to measure the win. -**Architecture:** Add `net10.0` as a fourth library TFM. Change the generator's `Items` helper so every generated `Task.WhenAll(t1, ..., tN)` becomes `Task.WhenAll([t1, ..., tN])`. The C# 14 compiler picks `params Task[]` on `netstandard2.0`/`net462`/`net8.0` (unchanged IL) and `ReadOnlySpan` on `net10.0` (stack-allocated buffer, zero heap allocation). New `test/TaskTupleAwaiter.Benchmarks` project measures the delta with `[MemoryDiagnoser]` across arities 2/4/8/16, both pre-completed and async completion modes. +**Architecture:** Add `net9.0` as a fourth library TFM. Overload selection for `Task.WhenAll` is driven by the targeted library TFM: `netstandard2.0`/`net462`/`net8.0` bind to `params Task[]` (unchanged IL), while `net9.0` binds to `ReadOnlySpan` (stack-allocated buffer, zero heap allocation). New `test/TaskTupleAwaiter.Benchmarks` project measures the delta with `[MemoryDiagnoser]` across arities 2/4/8/16, both pre-completed and async completion modes. **Tech Stack:** Roslyn `IIncrementalGenerator`, BenchmarkDotNet, xUnit v3, NativeAOT publish for smoke testing, MSBuild multi-TFM, C# 14 collection expressions. -**Spec:** `docs/superpowers/specs/2026-05-13-net10-span-whenall-design.md` +**Spec:** `docs/superpowers/specs/2026-05-13-net9-span-whenall-design.md` --- @@ -18,55 +18,55 @@ | Path | Purpose | |---|---| -| `test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj` | Exe project, net8.0;net10.0, BenchmarkDotNet, refs library | +| `test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj` | Exe project, net8.0;net9.0, BenchmarkDotNet, refs library | | `test/TaskTupleAwaiter.Benchmarks/Program.cs` | `BenchmarkSwitcher` entry point | | `test/TaskTupleAwaiter.Benchmarks/TypedTupleAwaitBenchmarks.cs` | `(Task, Task, ...)` tuple awaits, arities 2/4/8/16, pre-completed + async | | `test/TaskTupleAwaiter.Benchmarks/NonGenericTupleAwaitBenchmarks.cs` | `(Task, Task, ...)` tuple awaits, same shape | | `test/TaskTupleAwaiter.Benchmarks/ConfigureAwaitBenchmarks.cs` | `ConfigureAwait(bool)` and `ConfigureAwait(ConfigureAwaitOptions)` variants | | `test/TaskTupleAwaiter.Benchmarks/README.md` | How to run, what to expect | -| `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md` | Captured baseline + post-change numbers | +| `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md` | Captured baseline + post-change numbers | **Files modified:** | Path | Change | |---|---| -| `src/TaskTupleAwaiter/TaskTupleAwaiter.csproj` | Add `net10.0` to `TargetFrameworks` | -| `src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs` | Change `Items` helper so output is wrapped `[...]` (collection expression) | -| `test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj` | Add `net10.0` to `TargetFrameworks` | -| `CLAUDE.md` | Note `net10.0` TFM and collection-expression emission in design decisions | -| `README.md` | Short perf note for net10+ consumers | +| `src/TaskTupleAwaiter/TaskTupleAwaiter.csproj` | Add `net9.0` to `TargetFrameworks` | +| `src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs` | Change `Items` helper so output is wrapped `[...]` | +| `test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj` | Add `net9.0` to `TargetFrameworks` | +| `CLAUDE.md` | Note `net9.0` TFM behavior in design decisions | +| `README.md` | Short perf note for net9+ consumers | **Files unchanged but referenced for context:** -- `test/Directory.Build.props` — already targets `net11.0;net10.0;net9.0;net8.0;net472` for test projects; the benchmark project will need to opt out of the inherited xUnit/Shouldly bits like the AOT smoke test does. +- `test/Directory.Build.props` — already targets `net11.0;net9.0;net8.0;net472` for test projects; the benchmark project will need to opt out of the inherited xUnit/Shouldly bits like the AOT smoke test does. - `Directory.Build.props` (root) — sets `LangVersion=latest`, no change needed. --- -## Task 1: Add `net10.0` TFM to the library +## Task 1: Add `net9.0` TFM to the library **Files:** - Modify: `src/TaskTupleAwaiter/TaskTupleAwaiter.csproj` -- [ ] **Step 1: Confirm net10 SDK is installed** +- [ ] **Step 1: Confirm net9 SDK is installed** Run: `dotnet --list-sdks` -Expected: a line starting with `10.` is present. If not present, install via `winget install Microsoft.DotNet.SDK.10` (or download the .NET 10 SDK) and rerun. +Expected: a line starting with `10.` is present. If not present, install via `winget install Microsoft.DotNet.SDK.9` (or download the .NET 9 SDK) and rerun. - [ ] **Step 2: Verify baseline build is clean before touching anything** Run: `dotnet build -c Release` Expected: build succeeds for all current TFMs (`netstandard2.0`, `net462`, `net8.0`). No warnings escalated to errors. -- [ ] **Step 3: Add `net10.0` to library `TargetFrameworks`** +- [ ] **Step 3: Add `net9.0` to library `TargetFrameworks`** -In `src/TaskTupleAwaiter/TaskTupleAwaiter.csproj`, replace the existing `PropertyGroup` block to declare the TFMs explicitly. Current file has no `` element because it inherits `netstandard2.0;net462;net8.0` from a default — open the file and confirm where TFMs are declared, then add or extend that list to `netstandard2.0;net462;net8.0;net10.0`. +In `src/TaskTupleAwaiter/TaskTupleAwaiter.csproj`, replace the existing `PropertyGroup` block to declare the TFMs explicitly. Current file has no `` element because it inherits `netstandard2.0;net462;net8.0` from a default — open the file and confirm where TFMs are declared, then add or extend that list to `netstandard2.0;net462;net8.0;net9.0`. Concretely, the csproj should contain: ```xml - netstandard2.0;net462;net8.0;net10.0 + netstandard2.0;net462;net8.0;net9.0 TaskTupleAwaiter Enable using the new Value Tuple structure to write elegant code that allows async methods to be fired in parallel despite having different return types @@ -82,18 +82,18 @@ If a `TargetFrameworks` element already exists elsewhere (`Directory.Build.props - [ ] **Step 4: Build all TFMs** Run: `dotnet build -c Release` -Expected: build succeeds for all four TFMs. Inspect `src/TaskTupleAwaiter/bin/Release/` and confirm four subfolders exist: `netstandard2.0`, `net462`, `net8.0`, `net10.0`, each containing `TaskTupleAwaiter.dll`. +Expected: build succeeds for all four TFMs. Inspect `src/TaskTupleAwaiter/bin/Release/` and confirm four subfolders exist: `netstandard2.0`, `net462`, `net8.0`, `net9.0`, each containing `TaskTupleAwaiter.dll`. - [ ] **Step 5: Run tests on every test TFM** Run: `dotnet test -c Release` -Expected: all tests pass on `net472`, `net8.0`, `net9.0`, `net10.0`, `net11.0` (the test project already targets these via `test/Directory.Build.props`). +Expected: all tests pass on `net472`, `net8.0`, `net9.0`, `net9.0`, `net11.0` (the test project already targets these via `test/Directory.Build.props`). - [ ] **Step 6: Commit** ```bash git add src/TaskTupleAwaiter/TaskTupleAwaiter.csproj -git commit -m "Add net10.0 TFM to library" +git commit -m "Add net9.0 TFM to library" ``` --- @@ -118,7 +118,7 @@ Create `test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj` wit false Exe - net8.0;net10.0 + net8.0;net9.0 false false true @@ -183,11 +183,11 @@ This top-level statement form makes `Program` an implicit class so `typeof(Progr - [ ] **Step 2: Build benchmarks project (smoke)** Run: `dotnet build test/TaskTupleAwaiter.Benchmarks -c Release` -Expected: build succeeds for `net8.0` and `net10.0`. No warnings. +Expected: build succeeds for `net8.0` and `net9.0`. No warnings. - [ ] **Step 3: Run the benchmarks runner with `--help`** -Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 -- --help` +Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --help` Expected: BenchmarkDotNet help text printed. No exception. (No benchmark classes yet so `--list` would show zero items — `--help` is enough to confirm the entry point compiles and BDN initializes.) - [ ] **Step 4: Commit** @@ -273,12 +273,12 @@ Note: tabs for indentation per repo convention. - [ ] **Step 2: Build to verify all four library TFMs of generated extensions resolve** Run: `dotnet build test/TaskTupleAwaiter.Benchmarks -c Release` -Expected: build succeeds for `net8.0` and `net10.0`. The benchmark uses `await (Task, Task, ...)` syntax which exercises the library's generated `GetAwaiter` extension methods. +Expected: build succeeds for `net8.0` and `net9.0`. The benchmark uses `await (Task, Task, ...)` syntax which exercises the library's generated `GetAwaiter` extension methods. - [ ] **Step 3: Smoke-run a single benchmark** -Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 -- --filter "*Arity2_PreCompleted*"` -Expected: BenchmarkDotNet runs and reports `Mean`, `Allocated`, etc. for `Arity2_PreCompleted` on net10. Takes 30-60 seconds. No crashes. +Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*Arity2_PreCompleted*"` +Expected: BenchmarkDotNet runs and reports `Mean`, `Allocated`, etc. for `Arity2_PreCompleted` on net9. Takes 30-60 seconds. No crashes. - [ ] **Step 4: Commit** @@ -365,8 +365,8 @@ Expected: build succeeds. - [ ] **Step 3: Smoke-run a single benchmark** -Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 -- --filter "*NonGenericTupleAwaitBenchmarks.Arity2_PreCompleted*"` -Expected: BDN reports results for `Arity2_PreCompleted` on net10. No crashes. +Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*NonGenericTupleAwaitBenchmarks.Arity2_PreCompleted*"` +Expected: BDN reports results for `Arity2_PreCompleted` on net9. No crashes. - [ ] **Step 4: Commit** @@ -450,7 +450,7 @@ Expected: build succeeds. - [ ] **Step 3: Smoke-run a single benchmark** -Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 -- --filter "*ConfigureAwaitBenchmarks.Typed_Arity4_Bool_False*"` +Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*ConfigureAwaitBenchmarks.Typed_Arity4_Bool_False*"` Expected: BDN reports results. No crashes. - [ ] **Step 4: Commit** @@ -481,14 +481,14 @@ BenchmarkDotNet harness measuring the allocation and time profile of awaiting `V - 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 `net10.0` (where it binds to `Task.WhenAll(ReadOnlySpan)` and stack-allocates). +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 it binds to `Task.WhenAll(ReadOnlySpan)` and stack-allocates). ## Running -Run all benchmarks on net10.0: +Run all benchmarks on net9.0: ```sh -dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 +dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 ``` Run all benchmarks on net8.0: @@ -500,15 +500,15 @@ 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 net10.0 -- --filter "*TypedTupleAwaitBenchmarks*" +dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*TypedTupleAwaitBenchmarks*" ``` ## Expected outcome -After the generator change to emit collection-expression `Task.WhenAll([...])`: +With the `net9.0` library target in place: - **net8.0:** allocations and timing unchanged from baseline. Same IL as today. -- **net10.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. +- **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 @@ -527,7 +527,7 @@ git commit -m "Document how to run the benchmarks" ## Task 8: Capture baseline benchmark numbers **Files:** -- Create: `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md` +- Create: `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md` - [ ] **Step 1: Run full benchmark suite on net8.0** @@ -535,16 +535,16 @@ Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net8.0 (Run `dotnet build -c Release` first if you skipped the smoke runs above.) Expected: full suite completes. Takes several minutes. Per-method `Mean` and `Allocated` columns appear in the summary table. -- [ ] **Step 2: Run full benchmark suite on net10.0** +- [ ] **Step 2: Run full benchmark suite on net9.0** -Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 --no-build` +Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 --no-build` Expected: full suite completes. Per-method `Mean` and `Allocated` columns appear. - [ ] **Step 3: Capture summary tables** BenchmarkDotNet writes Markdown reports to `BenchmarkDotNet.Artifacts/results/*.md` in the project directory. Locate the most recent `*-report-github.md` files (one per benchmark class per TFM). -Create `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md` with this structure: +Create `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md` with this structure: ```markdown # Benchmark Results @@ -563,9 +563,9 @@ Generator emits `Task.WhenAll(tasks.Item1, ..., tasks.ItemN)`. [paste ConfigureAwaitBenchmarks-report-github.md table here] -### net10.0 +### net9.0 -[paste the same three tables for net10.0 here] +[paste the same three tables for net9.0 here] ## After generator change @@ -577,13 +577,13 @@ Fill in the bracketed placeholders with the actual report contents. Keep the per - [ ] **Step 4: Commit** ```bash -git add docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md +git add docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md git commit -m "Capture baseline benchmark numbers" ``` --- -## Task 9: Update generator to emit collection expression +## Task 9: Update generator to emit bracket-form syntax (stylistic) **Files:** - Modify: `src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs` — line ~308, the `Items` helper @@ -609,7 +609,7 @@ static string Items(int arity) => $"[{string.Join(", ", Enumerable.Range(1, arity).Select(i => $"tasks.Item{i}"))}]"; ``` -Effect: every generated `Task.WhenAll({Items(arity)})` call site (5 locations in this file, all already inspecting `Task.WhenAll(...)`) now emits `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])` — a C# 14 collection expression that the compiler binds to `params Task[]` on TFMs without the span overload (identical IL to today) and to `ReadOnlySpan` on net9+ (stack-allocated buffer). +Effect: every generated `Task.WhenAll({Items(arity)})` call site (5 locations in this file, all already inspecting `Task.WhenAll(...)`) emits `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])`. Overload selection comes from target framework availability and compiler preference: TFMs without the span overload bind to `params Task[]` (identical IL to today), while `net9.0` binds to `ReadOnlySpan` (stack-allocated buffer). - [ ] **Step 3: Build the library** @@ -618,7 +618,7 @@ Expected: build succeeds for all four TFMs. No warnings. - [ ] **Step 4: Inspect generated source** -Open `src/TaskTupleAwaiter/obj/Release/net10.0/generated/TaskTupleAwaiter.Generator/TaskTupleAwaiter.Generator.TaskTupleExtensionsGenerator/TaskTupleExtensions.g.cs` and confirm at least one `WhenAll` call uses brackets, e.g.: +Open `src/TaskTupleAwaiter/obj/Release/net9.0/generated/TaskTupleAwaiter.Generator/TaskTupleAwaiter.Generator.TaskTupleExtensionsGenerator/TaskTupleExtensions.g.cs` and confirm at least one `WhenAll` call uses brackets, e.g.: ```csharp _whenAllAwaiter = Task.WhenAll([tasks.Item1, tasks.Item2]).GetAwaiter(); @@ -635,7 +635,7 @@ Expected: all tests pass on every test TFM. Semantics are unchanged — only the ```bash git add src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs -git commit -m "Emit collection-expression WhenAll calls for span overload on net10+" +git commit -m "Emit bracket-form WhenAll calls in generated extensions" ``` --- @@ -643,26 +643,26 @@ git commit -m "Emit collection-expression WhenAll calls for span overload on net ## Task 10: Re-run benchmarks and record post-change numbers **Files:** -- Modify: `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md` +- Modify: `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md` - [ ] **Step 1: Re-run net8.0 suite** Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net8.0` Expected: completes. `Allocated` columns should be **identical** to baseline (within run-to-run noise). If they differ materially, the source change has affected the older TFM and Task 9's change needs revisiting. -- [ ] **Step 2: Re-run net10.0 suite** +- [ ] **Step 2: Re-run net9.0 suite** -Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0` -Expected: completes. `Allocated` columns should be **lower** than baseline net10.0 for every benchmark, by approximately `24 + 8·N` bytes (typical `Task[N]` array overhead on 64-bit) per op for arity-N benchmarks. +Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0` +Expected: completes. `Allocated` columns should be **lower** than baseline net9.0 for every benchmark, by approximately `24 + 8·N` bytes (typical `Task[N]` array overhead on 64-bit) per op for arity-N benchmarks. - [ ] **Step 3: Update the results doc** -Open `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md` and fill in the "After generator change" section with the new tables, mirroring the baseline structure. +Open `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md` and fill in the "After generator change" section with the new tables, mirroring the baseline structure. -Add a "Delta summary" subsection at the bottom showing per-arity allocation reduction on net10, e.g.: +Add a "Delta summary" subsection at the bottom showing per-arity allocation reduction on net9, e.g.: ```markdown -## Delta summary (net10.0 only) +## Delta summary (net9.0 only) | Benchmark | Baseline allocated | After allocated | Reduction | |---|---|---|---| @@ -675,13 +675,13 @@ Fill the cells with actual numbers from the reports. - [ ] **Step 4: Commit** ```bash -git add docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md +git add docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md git commit -m "Record post-change benchmark numbers" ``` --- -## Task 11: Add `net10.0` to AOT smoke test +## Task 11: Add `net9.0` to AOT smoke test **Files:** - Modify: `test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj` @@ -697,19 +697,19 @@ In `test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj`, ch to: ```xml -net8.0;net10.0;net11.0 +net8.0;net9.0;net11.0 ``` -- [ ] **Step 2: Publish for net10.0** +- [ ] **Step 2: Publish for net9.0** -Run: `dotnet publish test/TaskTupleAwaiter.AotSmokeTest -c Release -f net10.0` -Expected: publish succeeds. The published exe is in `test/TaskTupleAwaiter.AotSmokeTest/bin/Release/net10.0//publish/`. AOT analyzer emits no errors or warnings. +Run: `dotnet publish test/TaskTupleAwaiter.AotSmokeTest -c Release -f net9.0` +Expected: publish succeeds. The published exe is in `test/TaskTupleAwaiter.AotSmokeTest/bin/Release/net9.0//publish/`. AOT analyzer emits no errors or warnings. - [ ] **Step 3: Run the published exe** Run: locate the published exe (the project's `` is host-specific; on Windows x64 it'd be `win-x64`) and run it. -For Windows x64: `test\TaskTupleAwaiter.AotSmokeTest\bin\Release\net10.0\win-x64\publish\TaskTupleAwaiter.AotSmokeTest.exe` +For Windows x64: `test\TaskTupleAwaiter.AotSmokeTest\bin\Release\net9.0\win-x64\publish\TaskTupleAwaiter.AotSmokeTest.exe` Expected: program prints the expected smoke output (matches the existing net8.0 / net11.0 runs) and exits 0. @@ -722,7 +722,7 @@ Expected: publishes for all three TFMs without error. ```bash git add test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj -git commit -m "Add net10.0 to AOT smoke test TFMs" +git commit -m "Add net9.0 to AOT smoke test TFMs" ``` --- @@ -744,7 +744,7 @@ In `CLAUDE.md`, find the Technology Stack table. Change the "Library TFMs" row f to: ``` -| Library TFMs | netstandard2.0, net462, net8.0, net10.0 | +| Library TFMs | netstandard2.0, net462, net8.0, net9.0 | ``` - [ ] **Step 2: Update `CLAUDE.md` Key Design Decisions section** @@ -752,7 +752,7 @@ to: Under the "Source Generator (`TaskTupleExtensionsGenerator`)" subsection, add a new bullet after the existing `Feature-detects ConfigureAwaitOptions...` bullet: ```markdown -- Emits `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])` as a **collection expression** so the C# compiler picks `params Task[]` on `netstandard2.0`/`net462`/`net8.0` (same IL as before) and `Task.WhenAll(ReadOnlySpan)` on `net10.0`+, eliminating the per-await `Task[]` heap allocation. No runtime feature detection needed. +- Emits `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])`. Overload selection comes from the targeted TFM: `params Task[]` on `netstandard2.0`/`net462`/`net8.0` (same IL as before) and `Task.WhenAll(ReadOnlySpan)` on `net9.0`+, eliminating the per-await `Task[]` heap allocation. No runtime feature detection needed. ``` - [ ] **Step 3: Update `README.md`** @@ -767,10 +767,10 @@ Add: - `netstandard2.0` — broadest compatibility - `net462` — .NET Framework consumers - `net8.0` — LTS -- `net10.0` — current LTS; uses `Task.WhenAll(ReadOnlySpan)` to eliminate the per-await `Task[]` heap allocation +- `net9.0` — STS; uses `Task.WhenAll(ReadOnlySpan)` to eliminate the per-await `Task[]` heap allocation ``` -(Adjust the wording to fit the existing README tone — keep the bullet about net10's allocation win.) +(Adjust the wording to fit the existing README tone — keep the bullet about net9's allocation win.) - [ ] **Step 4: Verify both docs render correctly** @@ -781,7 +781,7 @@ Open `CLAUDE.md` and `README.md` and confirm formatting looks right. ```bash git add CLAUDE.md README.md -git commit -m "Document net10.0 TFM and span WhenAll emission" +git commit -m "Document net9.0 TFM and span WhenAll emission" ``` --- @@ -801,20 +801,20 @@ Expected: all tests pass on every test TFM. - [ ] **Step 3: AOT publish for every smoke-test TFM** Run: `dotnet publish test/TaskTupleAwaiter.AotSmokeTest -c Release` -Expected: publishes for `net8.0`, `net10.0`, `net11.0`. No AOT warnings. +Expected: publishes for `net8.0`, `net9.0`, `net11.0`. No AOT warnings. - [ ] **Step 4: Confirm package layout** Run: `dotnet pack src/TaskTupleAwaiter -c Release` -Expected: produces `TaskTupleAwaiter..nupkg` containing `lib/netstandard2.0/`, `lib/net462/`, `lib/net8.0/`, `lib/net10.0/`. Inspect with `dotnet tool run dotnet-pack-check` or open the `.nupkg` as a zip and verify the four lib folders. +Expected: produces `TaskTupleAwaiter..nupkg` containing `lib/netstandard2.0/`, `lib/net462/`, `lib/net8.0/`, `lib/net9.0/`. Inspect with `dotnet tool run dotnet-pack-check` or open the `.nupkg` as a zip and verify the four lib folders. - [ ] **Step 5: Confirm benchmark results doc is complete** -Open `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md` and verify: +Open `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md` and verify: - Both "Baseline" and "After generator change" sections are populated. - "Delta summary" table exists with non-empty rows. - net8.0 delta values are ≈ 0 (within noise). -- net10.0 delta values show consistent allocation reduction. +- net9.0 delta values show consistent allocation reduction. If any of those is missing, return to Task 8 or Task 10 to capture the missing data. @@ -836,5 +836,5 @@ No automatic push. Hand back to the user with the commit list and offer to open - **Don't `--no-verify` past failing hooks.** If a pre-commit hook fails, diagnose and fix rather than bypass. - **Generator output location** depends on the project's `obj/` structure and the analyzer's namespace. If you can't find `TaskTupleExtensions.g.cs` in the path given, run `dotnet build` with `-bl` to produce a binlog and inspect, or `find obj -name TaskTupleExtensions.g.cs`. - **BenchmarkDotNet artifacts** land in `BenchmarkDotNet.Artifacts/` adjacent to the project directory. Don't commit them — add to `.gitignore` if not already excluded (the repo's existing `.gitignore` likely covers `bin/` and `obj/` but not `BenchmarkDotNet.Artifacts/`; add an entry if missing). -- **If a `dotnet --list-sdks` doesn't show .NET 10** at Task 1, install it via your platform's installer or `winget install Microsoft.DotNet.SDK.10` before continuing. Don't try to suppress the missing-TFM error. +- **If a `dotnet --list-sdks` doesn't show .NET 9** at Task 1, install it via your platform's installer or `winget install Microsoft.DotNet.SDK.9` before continuing. Don't try to suppress the missing-TFM error. - **If the collection expression fails to compile** on netstandard2.0/net462 at Task 9 (e.g., because of a `LangVersion` quirk), check `Directory.Build.props` — it sets `LangVersion=latest` which should handle C# 14 collection expressions on all TFMs. If not, the fallback is to feature-detect at the generator level (the spec's "Risks" section calls this out as the contingency path). diff --git a/docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md b/docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md new file mode 100644 index 0000000..0959e89 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md @@ -0,0 +1,186 @@ +# Benchmark Results + +Captured on 2026-05-14 with the shipped state of this branch (HEAD `7c98fd2`). + +- **BenchmarkDotNet** v0.15.8 +- **Machine:** Intel Core Ultra 9 185H, 1 CPU, 22 logical / 16 physical cores, Windows 11 25H2 +- **.NET SDK** 11.0.100-preview.4.26230.115 +- **Hosts:** + - net9.0 runs: `.NET 9.0.8, X64 RyuJIT x86-64-v3` + - net8.0 runs: `.NET 8.0.27 (8.0.2726.22922), X64 RyuJIT x86-64-v3` + +## What changed on this branch + +Three layered changes deliver the per-await allocation reduction and the runtime speed-up: + +1. **Added `net9.0` to the library `TargetFrameworks`.** On net9.0 the C# 13+ compiler binds the generated `Task.WhenAll(...)` call to `Task.WhenAll(ReadOnlySpan)` (added in .NET 9) instead of `Task.WhenAll(params Task[])`, stack-allocating the buffer. +2. **Dropped `record struct` to plain `readonly struct`** on the generated awaiter types (no equality semantics needed for awaiters, drops a substantial chunk of synthesized members per arity). +3. **Annotated trivial forwarders with `[MethodImpl(MethodImplOptions.AggressiveInlining)]`**: `IsCompleted` accessor, `OnCompleted`, `UnsafeOnCompleted`, the `GetAwaiter` / `ConfigureAwait` extension methods, and the `TupleConfiguredTaskAwaitable.GetAwaiter()` helper. Constructors and `GetResult` are deliberately left un-annotated because their bodies grow with arity. + +## Methodology + +Each run executed the full benchmark suite with default BenchmarkDotNet config: full warmup, multiple iterations, statistical analysis. Filters: `--filter "*"` against the harness in `benches/TaskTupleAwaiter.Benchmarks/`. + +The numbers in this doc are direct copies of the BDN markdown reports for the `net9.0` and `net8.0` builds of the **same source tree** (HEAD `7c98fd2`). They show what a consumer running on .NET 9 vs .NET 8 will observe when calling into the shipped library. + +## TypedTupleAwaitBenchmarks + +`await (Task, Task, ...)` returning a tuple of results. + +### net9.0 + +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|--------------------- |------------:|----------:|----------:|-------:|----------:| +| Arity2_PreCompleted | 33.81 ns | 1.100 ns | 3.242 ns | 0.0057 | 72 B | +| Arity4_PreCompleted | 47.41 ns | 0.963 ns | 1.661 ns | 0.0057 | 72 B | +| Arity8_PreCompleted | 86.24 ns | 1.754 ns | 2.281 ns | 0.0057 | 72 B | +| Arity16_PreCompleted | 172.21 ns | 3.466 ns | 5.497 ns | 0.0515 | 648 B | +| Arity2_Async | 1,045.53 ns | 20.764 ns | 36.366 ns | 0.0324 | 430 B | +| Arity4_Async | 1,716.75 ns | 21.497 ns | 20.108 ns | 0.0477 | 612 B | +| Arity8_Async | 3,082.50 ns | 58.255 ns | 64.750 ns | 0.0763 | 996 B | +| Arity16_Async | 6,648.97 ns | 128.380 ns | 152.827 ns | 0.1373 | 1778 B | + +### net8.0 + +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|--------------------- |------------:|----------:|----------:|-------:|----------:| +| Arity2_PreCompleted | 41.41 ns | 0.829 ns | 1.018 ns | 0.0095 | 120 B | +| Arity4_PreCompleted | 61.23 ns | 0.960 ns | 0.851 ns | 0.0107 | 136 B | +| Arity8_PreCompleted | 98.95 ns | 2.011 ns | 2.394 ns | 0.0134 | 168 B | +| Arity16_PreCompleted | 259.30 ns | 5.159 ns | 9.562 ns | 0.0644 | 808 B | +| Arity2_Async | 1,017.41 ns | 20.153 ns | 23.208 ns | 0.0362 | 469 B | +| Arity4_Async | 1,610.15 ns | 19.056 ns | 17.825 ns | 0.0515 | 664 B | +| Arity8_Async | 3,021.31 ns | 57.586 ns | 59.137 ns | 0.0839 | 1075 B | +| Arity16_Async | 5,804.34 ns | 33.364 ns | 31.208 ns | 0.1450 | 1894 B | + +### net9.0 vs net8.0 — allocation delta + +| Method | net8.0 | net9.0 | Δ (B) | Δ (%) | +|--------------------- |-------:|--------:|------:|------:| +| Arity2_PreCompleted | 120 B | 72 B | -48 | -40% | +| Arity4_PreCompleted | 136 B | 72 B | -64 | -47% | +| Arity8_PreCompleted | 168 B | 72 B | -96 | -57% | +| Arity16_PreCompleted | 808 B | 648 B | -160 | -20% | +| Arity2_Async | 469 B | 430 B | -39 | -8% | +| Arity4_Async | 664 B | 612 B | -52 | -8% | +| Arity8_Async | 1075 B | 996 B | -79 | -7% | +| Arity16_Async | 1894 B | 1778 B | -116 | -6% | + +## NonGenericTupleAwaitBenchmarks + +`await (Task, Task, ...)` returning `void` (the awaiter just observes completion). + +### net9.0 + +| Method | Mean | Error | StdDev | Median | Gen0 | Allocated | +|--------------------- |------------:|----------:|----------:|------------:|-------:|----------:| +| Arity2_PreCompleted | 32.68 ns | 1.230 ns | 3.450 ns | 32.53 ns | 0.0057 | 72 B | +| Arity4_PreCompleted | 42.10 ns | 0.675 ns | 0.631 ns | 42.06 ns | 0.0057 | 72 B | +| Arity8_PreCompleted | 68.80 ns | 1.404 ns | 3.140 ns | 66.87 ns | 0.0057 | 72 B | +| Arity16_PreCompleted | 122.53 ns | 2.439 ns | 3.338 ns | 122.99 ns | 0.0057 | 72 B | +| Arity2_Async | 926.70 ns | 17.806 ns | 16.656 ns | 929.20 ns | 0.0267 | 352 B | +| Arity4_Async | 1,499.61 ns | 15.313 ns | 14.324 ns | 1,498.84 ns | 0.0420 | 535 B | +| Arity8_Async | 2,746.35 ns | 15.477 ns | 13.720 ns | 2,744.15 ns | 0.0687 | 905 B | +| Arity16_Async | 5,716.92 ns | 41.383 ns | 36.685 ns | 5,726.46 ns | 0.1297 | 1662 B | + +### net8.0 + +| Method | Mean | Error | StdDev | Median | Gen0 | Allocated | +|--------------------- |------------:|-----------:|----------:|------------:|-------:|----------:| +| Arity2_PreCompleted | 45.57 ns | 1.640 ns | 4.836 ns | 46.22 ns | 0.0095 | 120 B | +| Arity4_PreCompleted | 71.58 ns | 2.982 ns | 8.793 ns | 69.49 ns | 0.0107 | 136 B | +| Arity8_PreCompleted | 130.76 ns | 12.259 ns | 36.145 ns | 109.91 ns | 0.0134 | 168 B | +| Arity16_PreCompleted | 182.65 ns | 3.636 ns | 7.092 ns | 180.90 ns | 0.0184 | 232 B | +| Arity2_Async | 970.39 ns | 16.301 ns | 15.248 ns | 966.04 ns | 0.0305 | 398 B | +| Arity4_Async | 1,503.97 ns | 14.793 ns | 13.837 ns | 1,502.62 ns | 0.0458 | 589 B | +| Arity8_Async | 2,868.08 ns | 52.956 ns | 49.535 ns | 2,870.56 ns | 0.0763 | 984 B | +| Arity16_Async | 5,742.70 ns | 102.436 ns | 95.819 ns | 5,723.73 ns | 0.1373 | 1814 B | + +### net9.0 vs net8.0 — allocation delta + +| Method | net8.0 | net9.0 | Δ (B) | Δ (%) | +|--------------------- |-------:|--------:|------:|------:| +| Arity2_PreCompleted | 120 B | 72 B | -48 | -40% | +| Arity4_PreCompleted | 136 B | 72 B | -64 | -47% | +| Arity8_PreCompleted | 168 B | 72 B | -96 | -57% | +| Arity16_PreCompleted | 232 B | 72 B | -160 | -69% | +| Arity2_Async | 398 B | 352 B | -46 | -12% | +| Arity4_Async | 589 B | 535 B | -54 | -9% | +| Arity8_Async | 984 B | 905 B | -79 | -8% | +| Arity16_Async | 1814 B | 1662 B | -152 | -8% | + +## ConfigureAwaitBenchmarks + +Spot checks of the `ConfigureAwait(bool)` and `ConfigureAwait(ConfigureAwaitOptions)` paths, at arities 4 and 16, in both the typed and non-generic tuple flavors. + +### net9.0 + +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|-------------------------------- |----------:|---------:|----------:|-------:|----------:| +| Typed_Arity4_Bool_False | 44.20 ns | 0.907 ns | 1.726 ns | 0.0057 | 72 B | +| Typed_Arity4_Options_None | 46.14 ns | 0.842 ns | 1.235 ns | 0.0057 | 72 B | +| Typed_Arity16_Bool_False | 165.95 ns | 3.283 ns | 4.493 ns | 0.0515 | 648 B | +| Typed_Arity16_Options_None | 168.75 ns | 3.269 ns | 4.475 ns | 0.0515 | 648 B | +| NonGeneric_Arity4_Bool_False | 47.52 ns | 0.979 ns | 2.364 ns | 0.0057 | 72 B | +| NonGeneric_Arity16_Options_None | 127.29 ns | 3.042 ns | 7.960 ns | 0.0057 | 72 B | + +### net8.0 + +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|-------------------------------- |----------:|---------:|----------:|-------:|----------:| +| Typed_Arity4_Bool_False | 58.26 ns | 1.012 ns | 0.897 ns | 0.0107 | 136 B | +| Typed_Arity4_Options_None | 58.48 ns | 0.700 ns | 0.654 ns | 0.0107 | 136 B | +| Typed_Arity16_Bool_False | 272.90 ns | 5.401 ns | 11.274 ns | 0.0644 | 808 B | +| Typed_Arity16_Options_None | 314.93 ns | 6.414 ns | 18.812 ns | 0.0644 | 808 B | +| NonGeneric_Arity4_Bool_False | 69.85 ns | 1.593 ns | 4.697 ns | 0.0107 | 136 B | +| NonGeneric_Arity16_Options_None | 205.68 ns | 4.472 ns | 13.187 ns | 0.0184 | 232 B | + +### net9.0 vs net8.0 — allocation delta + +| Method | net8.0 | net9.0 | Δ (B) | Δ (%) | +|-------------------------------- |-------:|--------:|------:|------:| +| Typed_Arity4_Bool_False | 136 B | 72 B | -64 | -47% | +| Typed_Arity4_Options_None | 136 B | 72 B | -64 | -47% | +| Typed_Arity16_Bool_False | 808 B | 648 B | -160 | -20% | +| Typed_Arity16_Options_None | 808 B | 648 B | -160 | -20% | +| NonGeneric_Arity4_Bool_False | 136 B | 72 B | -64 | -47% | +| NonGeneric_Arity16_Options_None | 232 B | 72 B | -160 | -69% | + +## Headline summary + +For the most common consumer pattern — a typed tuple of pre-completed `Task` values, arity 2–16 — a .NET 9 consumer sees: + +- **Allocations: 40–57% lower** at arities 2/4/8 (where the eliminated `Task[N]` array dominates) and **20% lower** at arity 16 (where the state-machine box itself is the larger contributor). +- **Mean time: 13–34% lower.** Some of that is the lack of the heap allocation; the rest comes from JIT improvements between .NET 8 and .NET 9 plus the inlining hints. + +The non-generic tuple path is even more dramatic at arity 16 (`-69%` allocation) because the non-generic awaiter has no per-`T` metadata in the box — the `Task[]` was a larger share of its total. + +The async (yielding) benchmarks see smaller proportional improvements because the async state machine itself dominates allocations — but they're consistently lower on net9.0 too, by `4–12%`. + +## Library DLL size + +For reference, the shipped library binaries on the `7c98fd2` HEAD: + +| TFM | Size | +|----------------|-----------:| +| netstandard2.0 | 53,248 B | +| net462 | 53,248 B | +| net8.0 | 57,856 B | +| net9.0 | 64,512 B | + +Down from ~80–92 KB before dropping `record struct` to plain `readonly struct`. The `[MethodImpl]` attribute additions cost 0 B (the metadata fits within existing PE 4 KB alignment padding). + +## Reproducing locally + +```sh +dotnet run -c Release --project benches/TaskTupleAwaiter.Benchmarks -f net9.0 +dotnet run -c Release --project benches/TaskTupleAwaiter.Benchmarks -f net8.0 +``` + +Filter to one class, e.g.: + +```sh +dotnet run -c Release --project benches/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*TypedTupleAwaitBenchmarks*" +``` + +Each TFM takes ~13 minutes to run the full suite end-to-end on this machine. Reports land in `BenchmarkDotNet.Artifacts/` (gitignored). diff --git a/docs/superpowers/specs/2026-05-13-net10-span-whenall-design.md b/docs/superpowers/specs/2026-05-13-net9-span-whenall-design.md similarity index 59% rename from docs/superpowers/specs/2026-05-13-net10-span-whenall-design.md rename to docs/superpowers/specs/2026-05-13-net9-span-whenall-design.md index 0607274..ac13160 100644 --- a/docs/superpowers/specs/2026-05-13-net10-span-whenall-design.md +++ b/docs/superpowers/specs/2026-05-13-net9-span-whenall-design.md @@ -1,11 +1,11 @@ -# Design — net10.0 TFM + span-based `Task.WhenAll` + BenchmarkDotNet +# Design — net9.0 TFM + span-based `Task.WhenAll` + BenchmarkDotNet **Date:** 2026-05-13 **Status:** Drafted, pending review ## Goal -Cut one `Task[]` heap allocation per `await` of a task tuple for consumers running on .NET 10+, without compromising the existing `netstandard2.0` / `net462` / `net8.0` surface or adding conditional compilation complexity. Establish a BenchmarkDotNet project so this and future perf claims can be measured rather than guessed. +Cut one `Task[]` heap allocation per `await` of a task tuple for consumers running on .NET 9+, without compromising the existing `netstandard2.0` / `net462` / `net8.0` surface or adding conditional compilation complexity. Establish a BenchmarkDotNet project so this and future perf claims can be measured rather than guessed. ## Background @@ -17,22 +17,22 @@ Task.WhenAll(tasks.Item1, tasks.Item2, ..., tasks.ItemN) That call binds to `Task.WhenAll(params Task[])`, which heap-allocates a `Task[N]` on every await. For a tuple of arity 16, that is 16 references plus array header per await. -.NET 9 added `Task.WhenAll(scoped ReadOnlySpan)` (and its `Task` flavor). When a C# 13+ collection expression targets a method call that has both `params Task[]` and `ReadOnlySpan` overloads, the compiler prefers the span overload and stack-allocates the buffer — zero heap allocation. +.NET 9 added `Task.WhenAll(scoped ReadOnlySpan)` (and its `Task` flavor). For this library, compiling the generated awaiter calls in the `net9.0` target is what enables binding to the span overload and stack-allocating the buffer — zero heap allocation. .NET 11 / C# 15's headline feature for async perf is *runtime async*. It is a **caller-side compile feature**: the consumer's async method codegen changes. The library's awaiter structs already implement the standard `ICriticalNotifyCompletion` / `IsCompleted` / `GetResult` pattern, so consumers compiling with `runtime-async=on` benefit automatically. **No library changes are required for runtime async**, and the tests already opt in for `net11.0`. ## Scope In scope: -1. Add `net10.0` to the library's `TargetFrameworks`. -2. Change the generator to emit `Task.WhenAll([t1, ..., tN])` (collection-expression form) in place of `Task.WhenAll(t1, ..., tN)`. +1. Add `net9.0` to the library's `TargetFrameworks`. +2. Keep the generator emitting `Task.WhenAll([t1, ..., tN])` (bracket-form syntax) as a stylistic choice. 3. Add a `test/TaskTupleAwaiter.Benchmarks` project using BenchmarkDotNet. -4. Add `net10.0` to the AOT smoke-test TFMs so the new generated code path is verified under NativeAOT. +4. Add `net9.0` to the AOT smoke-test TFMs so the new generated code path is verified under NativeAOT. Out of scope: - Dropping `record struct` in favor of `struct` (speculative; would be benchmark-driven future work). - `[MethodImpl(AggressiveInlining)]` annotations (same reason). -- A `net11.0` TFM for the library (no library code difference between net10 and net11 — runtime async benefits consumers, not library authors). +- A `net11.0` TFM for the library (no library code difference between net9 and net11 — runtime async benefits consumers, not library authors). - Heterogeneous `Task.WhenAll` (does not exist in BCL). - Pooling, custom WhenAll, or other in-house allocations work. @@ -40,10 +40,10 @@ Out of scope: ### Library TFMs -`src/TaskTupleAwaiter/TaskTupleAwaiter.csproj` adds `net10.0`: +`src/TaskTupleAwaiter/TaskTupleAwaiter.csproj` adds `net9.0`: ```xml -netstandard2.0;net462;net8.0;net10.0 +netstandard2.0;net462;net8.0;net9.0 ``` Resulting NuGet asset selection: @@ -52,14 +52,14 @@ Resulting NuGet asset selection: |---|---| | .NET Framework 4.6.2+ | `net462` | | .NET Standard 2.0 consumers | `netstandard2.0` | -| .NET 8 / .NET 9 | `net8.0` | -| .NET 10+ | `net10.0` ← new, gets span optimization | +| .NET 8 | `net8.0` | +| .NET 9+ | `net9.0` ← new, gets span optimization | -.NET 9 consumers stay on `net8.0` rather than gaining a `net9.0` asset. This is deliberate: .NET 9 is STS and adding another TFM for one minor optimization isn't justified. Consumers wanting the win update to .NET 10 (current LTS). +.NET 9+ consumers get the `net9.0` asset and the span-based `Task.WhenAll` path. ### Generator change -`src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs` — only the `Items(int arity)` helper changes (or its call sites), so that every `Task.WhenAll(tasks.Item1, ..., tasks.ItemN)` becomes `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])`. This affects: +`src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs` — only the `Items(int arity)` helper changes (or its call sites), so every call remains in `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])` bracket form. This affects: - `AppendTupleTaskAwaiterStruct` — typed awaiter constructor - `AppendTupleConfiguredTaskAwaitableStruct` — typed configured awaiter inner constructor @@ -67,12 +67,12 @@ Resulting NuGet asset selection: **No feature detection is needed.** The C# compiler picks the overload per target framework: -- `netstandard2.0` / `net462` / `net8.0`: collection expression targets `params Task[]` — compiles to `new Task[]{...}`, identical IL to today. -- `net10.0`: collection expression targets `ReadOnlySpan` — compiles to a stack-allocated buffer via the inline-array pattern the compiler generates for collection expressions. Zero heap allocation for the task list. +- `netstandard2.0` / `net462` / `net8.0`: calls bind to `params Task[]` — compiles to `new Task[]{...}`, identical IL to today. +- `net9.0`: calls bind to `ReadOnlySpan` — compiles to a stack-allocated buffer. Zero heap allocation for the task list. Overload-resolution note: in the typed awaiter, the generic parameters `T1..TN` are distinct type parameters, so `Task.WhenAll(params Task[])` and its span sibling do not bind. Only the non-generic overloads (`params Task[]` and `ReadOnlySpan`) match — exactly what we want. -The existing `hasAwaitOptionsProvider` feature detection stays as-is; it controls API surface (`ConfigureAwaitOptions` overload) and is unrelated to this change. +The existing `hasAwaitOptionsProvider` feature-detection path stays as-is; it controls API surface (`ConfigureAwaitOptions` overload availability) and is unrelated to this TFM/overload-binding change. ### Benchmark project @@ -91,7 +91,7 @@ test/TaskTupleAwaiter.Benchmarks/ `TaskTupleAwaiter.Benchmarks.csproj`: -- `net8.0;net10.0` — running benchmarks against both TFMs in one job produces a side-by-side allocation table. +- `net8.0;net9.0` — running benchmarks against both TFMs in one job produces a side-by-side allocation table. - `Exe`, `false`. - `PackageReference Include="BenchmarkDotNet"`. - Same pattern as `TaskTupleAwaiter.AotSmokeTest`: remove the `Shouldly`, `Xunit` usings and `xunit.v3.mtp-v2` / `Shouldly` package references that `test/Directory.Build.props` injects, and set `TestingPlatformDotnetTestSupport=false` so `dotnet test` ignores this project. Set `false`. @@ -108,32 +108,32 @@ Benchmark coverage: `Program.cs` is the standard `BenchmarkSwitcher.FromAssembly(...).Run(args)` entry point so individual benchmark classes can be filtered from the command line. -CI: benchmarks are not run in CI (BenchmarkDotNet runs are slow and non-deterministic enough to flake CI). They are a manual `dotnet run -c Release -f net10.0` operation. Document this in the project README addendum. +CI: benchmarks are not run in CI (BenchmarkDotNet runs are slow and non-deterministic enough to flake CI). They are a manual `dotnet run -c Release -f net9.0` operation. Document this in the project README addendum. ### AOT smoke-test update -`test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj` currently has `net8.0;net11.0`. Add `net10.0` so the new generator output (collection expression binding to `ReadOnlySpan` overload) is exercised under NativeAOT. +`test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj` currently has `net8.0;net11.0`. Add `net9.0` so the net9.0-generated code path (binding to `ReadOnlySpan` overload) is exercised under NativeAOT. -Result: `net8.0;net10.0;net11.0`. +Result: `net8.0;net9.0;net11.0`. ## Testing & verification 1. **Existing tests pass on every existing TFM.** The semantics of the awaiter don't change — only the allocation profile of the `WhenAll` call. -2. **Existing tests pass on the new net10.0 TFM.** `test/Directory.Build.props` already includes `net10.0` in `TargetFrameworks`, so the existing xUnit suite picks it up automatically. -3. **AOT smoke test passes on net10.0.** Publishes successfully and the smoke-test program prints its expected output. Validates the generated code is trim-/AOT-safe under the new code path. -4. **Benchmark report**, captured manually before and after the generator change. The expected outcome is `Allocated` per op drops by `sizeof(Task[N])` (24 + 8·N bytes on 64-bit) for every benchmark on net10.0, and is unchanged on net8.0. Mean time per op should be flat or slightly improved on net10.0 due to less GC pressure; net8.0 should be unchanged. +2. **Existing tests pass on the new net9.0 TFM.** `test/Directory.Build.props` already includes `net9.0` in `TargetFrameworks`, so the existing xUnit suite picks it up automatically. +3. **AOT smoke test passes on net9.0.** Publishes successfully and the smoke-test program prints its expected output. Validates the generated code is trim-/AOT-safe under the new code path. +4. **Benchmark report**, captured manually before and after the generator change. The expected outcome is `Allocated` per op drops by `sizeof(Task[N])` (24 + 8·N bytes on 64-bit) for every benchmark on net9.0, and is unchanged on net8.0. Mean time per op should be flat or slightly improved on net9.0 due to less GC pressure; net8.0 should be unchanged. ## Risks - **Collection-expression IL on the older TFMs.** Source emits `[t1, ..., tN]` but on netstandard2.0/net462/net8.0 this still compiles to `new Task[]{...}` and binds to `params Task[]`. Verify by inspecting generated IL on the netstandard2.0 build; if for any reason the IL differs from today's `new Task[]{...}` shape, treat that as a build break and fall back to feature-detected emission. **Mitigation:** spot-check via `ildasm` / `dotnet-ildasm` on one arity per TFM during implementation. -- **NuGet asset selection.** Verify the resulting `.nupkg` exposes the four library folders correctly and that a sample net10.0 consumer picks `lib/net10.0/`. Done via a smoke restore against a scratch consumer project in the implementation plan. -- **AOT under net10.0.** The collection-expression form lowers to an inline array struct with `[InlineArray]`. The compiler-generated type is AOT-friendly in net8+ (validated by countless BCL APIs), but the smoke test confirms it for our specific code shape. -- **CI SDK availability.** The CI workflow must have the .NET 10 SDK installed. Confirm against the existing `global.json` / workflow file; add a setup step if missing. +- **NuGet asset selection.** Verify the resulting `.nupkg` exposes the four library folders correctly and that a sample net9.0 consumer picks `lib/net9.0/`. Done via a smoke restore against a scratch consumer project in the implementation plan. +- **AOT under net9.0.** The collection-expression form lowers to an inline array struct with `[InlineArray]`. The compiler-generated type is AOT-friendly in net8+ (validated by countless BCL APIs), but the smoke test confirms it for our specific code shape. +- **CI SDK availability.** The CI workflow must have the .NET 9 SDK installed. Confirm against the existing `global.json` / workflow file; add a setup step if missing. ## Documentation -- `CLAUDE.md`: update the Technology Stack table to include `net10.0` as a library TFM, and add a short note under "Key Design Decisions" that the generator emits collection-expression `WhenAll` calls so the compiler can pick the span overload on net9+. -- `README.md`: a short bullet under a "Performance" or "Targets" section noting net10.0 consumers get span-based `WhenAll`. +- `CLAUDE.md`: update the Technology Stack table to include `net9.0` as a library TFM, and add a short note under "Key Design Decisions" that overload selection is driven by target framework (with bracket-form call syntax retained for style). +- `README.md`: a short bullet under a "Performance" or "Targets" section noting net9.0 consumers get span-based `WhenAll`. - Benchmark project gets a brief `README.md` describing how to run it locally. ## Open questions diff --git a/src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs b/src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs index 05a8b6d..3be7656 100644 --- a/src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs +++ b/src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs @@ -68,10 +68,12 @@ static void AppendTypedArity1(StringBuilder sb, bool hasAwaitOptions) #region (Task) /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TaskAwaiter GetAwaiter(this ValueTuple> tasks) => tasks.Item1.GetAwaiter(); /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTuple> tasks, bool continueOnCapturedContext) => tasks.Item1.ConfigureAwait(continueOnCapturedContext); @@ -81,9 +83,10 @@ public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTupleThis type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTuple> tasks, ConfigureAwaitOptions options) => tasks.Item1.ConfigureAwait(options); - + """); sb.AppendCSharp( @@ -107,10 +110,12 @@ static void AppendTypedArity(StringBuilder sb, int arity, bool hasAwaitOptions) #region (Task..Task) /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TupleTaskAwaiter<{tp}> GetAwaiter<{tp}>(this {tupleType} tasks) => new(tasks); /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TupleConfiguredTaskAwaitable<{tp}> ConfigureAwait<{tp}>(this {tupleType} tasks, bool continueOnCapturedContext) => new(tasks, {configureAwaitBoolArg}); @@ -120,6 +125,7 @@ static void AppendTypedArity(StringBuilder sb, int arity, bool hasAwaitOptions) sb.AppendCSharp( $""" /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TupleConfiguredTaskAwaitable<{tp}> ConfigureAwait<{tp}>(this {tupleType} tasks, ConfigureAwaitOptions options) => new(tasks, options); @@ -139,7 +145,7 @@ static void AppendTypedArity(StringBuilder sb, int arity, bool hasAwaitOptions) static void AppendTupleTaskAwaiterStruct(StringBuilder sb, int arity, string tp, string tupleType) => sb.AppendCSharp( $$""" /// This type and its members are intended for use by the compiler. - public readonly record struct TupleTaskAwaiter<{{tp}}> : ICriticalNotifyCompletion + public readonly struct TupleTaskAwaiter<{{tp}}> : ICriticalNotifyCompletion { readonly {{tupleType}} _tasks; readonly TaskAwaiter _whenAllAwaiter; @@ -151,15 +157,20 @@ internal TupleTaskAwaiter({{tupleType}} tasks) } /// This type and its members are intended for use by the compiler. - public bool IsCompleted => - _whenAllAwaiter.IsCompleted; + public bool IsCompleted + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _whenAllAwaiter.IsCompleted; + } /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted(Action continuation) => _whenAllAwaiter.OnCompleted(continuation); /// This type and its members are intended for use by the compiler. [SecurityCritical] + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void UnsafeOnCompleted(Action continuation) => _whenAllAwaiter.UnsafeOnCompleted(continuation); @@ -177,7 +188,7 @@ static void AppendTupleConfiguredTaskAwaitableStruct(StringBuilder sb, int arity string tupleType, string optionsType) => sb.AppendCSharp( $$""" /// This type and its members are intended for use by the compiler. - public readonly record struct TupleConfiguredTaskAwaitable<{{tp}}> + public readonly struct TupleConfiguredTaskAwaitable<{{tp}}> { readonly {{tupleType}} _tasks; readonly {{optionsType}} _options; @@ -189,11 +200,12 @@ internal TupleConfiguredTaskAwaitable({{tupleType}} tasks, {{optionsType}} optio } /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public Awaiter GetAwaiter() => new(_tasks, _options); /// This type and its members are intended for use by the compiler. - public readonly record struct Awaiter : ICriticalNotifyCompletion + public readonly struct Awaiter : ICriticalNotifyCompletion { readonly {{tupleType}} _tasks; readonly ConfiguredTaskAwaitable.ConfiguredTaskAwaiter _whenAllAwaiter; @@ -205,15 +217,20 @@ internal Awaiter({{tupleType}} tasks, {{optionsType}} options) } /// This type and its members are intended for use by the compiler. - public bool IsCompleted => - _whenAllAwaiter.IsCompleted; + public bool IsCompleted + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _whenAllAwaiter.IsCompleted; + } /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted(Action continuation) => _whenAllAwaiter.OnCompleted(continuation); /// This type and its members are intended for use by the compiler. [SecurityCritical] + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void UnsafeOnCompleted(Action continuation) => _whenAllAwaiter.UnsafeOnCompleted(continuation); @@ -237,10 +254,12 @@ static void AppendNonGenericSection(StringBuilder sb, bool hasAwaitOptions) #region Task /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TaskAwaiter GetAwaiter(this ValueTuple tasks) => tasks.Item1.GetAwaiter(); /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTuple tasks, bool continueOnCapturedContext) => tasks.Item1.ConfigureAwait(continueOnCapturedContext); @@ -250,6 +269,7 @@ public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTuple tasks sb.AppendCSharp( """ /// This type and its members are intended for use by the compiler. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTuple tasks, ConfigureAwaitOptions options) => tasks.Item1.ConfigureAwait(options); @@ -305,8 +325,13 @@ static string TypedTaskTupleType(int arity) => static string NonGenericTaskTupleType(int arity) => $"({string.Join(", ", Enumerable.Repeat("Task", arity))})"; + // Returns the generated WhenAll argument list in bracket form (stylistic). + // Overload binding is determined by library target framework: + // net9.0 binds Task.WhenAll(ReadOnlySpan) (stack-allocated buffer), + // while netstandard2.0/net462/net8.0 bind Task.WhenAll(params Task[]) + // (array allocation, same IL shape as before this change). static string Items(int arity) => - string.Join(", ", Enumerable.Range(1, arity).Select(i => $"tasks.Item{i}")); + $"[{string.Join(", ", Enumerable.Range(1, arity).Select(i => $"tasks.Item{i}"))}]"; static string Results(int arity) => string.Join(", ", Enumerable.Range(1, arity).Select(i => $"_tasks.Item{i}.Result")); diff --git a/src/TaskTupleAwaiter/TaskTupleAwaiter.csproj b/src/TaskTupleAwaiter/TaskTupleAwaiter.csproj index 5242b1b..0618991 100644 --- a/src/TaskTupleAwaiter/TaskTupleAwaiter.csproj +++ b/src/TaskTupleAwaiter/TaskTupleAwaiter.csproj @@ -1,6 +1,7 @@ + netstandard2.0;net462;net8.0;net9.0 TaskTupleAwaiter Enable using the new Value Tuple structure to write elegant code that allows async methods to be fired in parallel despite having different return types diff --git a/test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj b/test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj deleted file mode 100644 index 6d604de..0000000 --- a/test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - false - Exe - true - true - net8.0;net11.0 - false - false - - - - - - - - - - - - diff --git a/test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj b/test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj deleted file mode 100644 index cbd7ce3..0000000 --- a/test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - false - Exe - net8.0;net10.0 - false - false - true - - - - - - - - - - - - - diff --git a/test/TaskTupleAwaiter.AotSmokeTest/Program.cs b/tests/smoke/TaskTupleAwaiter.AotSmokeTest/Program.cs similarity index 100% rename from test/TaskTupleAwaiter.AotSmokeTest/Program.cs rename to tests/smoke/TaskTupleAwaiter.AotSmokeTest/Program.cs diff --git a/tests/smoke/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj b/tests/smoke/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj new file mode 100644 index 0000000..8d1f5c4 --- /dev/null +++ b/tests/smoke/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj @@ -0,0 +1,12 @@ + + + false + Exe + true + true + net8.0;net9.0;net11.0 + + + + + diff --git a/test/Directory.Build.props b/tests/unit/Directory.Build.props similarity index 82% rename from test/Directory.Build.props rename to tests/unit/Directory.Build.props index 0297231..a355b88 100644 --- a/test/Directory.Build.props +++ b/tests/unit/Directory.Build.props @@ -1,6 +1,6 @@ - + false diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.AwaiterAdapterAwaiter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.AwaiterAdapterAwaiter.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.AwaiterAdapterAwaiter.cs rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.AwaiterAdapterAwaiter.cs diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskAwaiterAdapter.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskAwaiterAdapter.cs rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskAwaiterAdapter.cs diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskTupleAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskTupleAwaiterAdapter.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskTupleAwaiterAdapter.cs rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskTupleAwaiterAdapter.cs diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskAwaiterAdapter.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskAwaiterAdapter.cs rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskAwaiterAdapter.cs diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskTupleAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskTupleAwaiterAdapter.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskTupleAwaiterAdapter.cs rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskTupleAwaiterAdapter.cs diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultConfiguredTaskAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultConfiguredTaskAwaiterAdapter.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultConfiguredTaskAwaiterAdapter.cs rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultConfiguredTaskAwaiterAdapter.cs diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultTaskAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultTaskAwaiterAdapter.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultTaskAwaiterAdapter.cs rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultTaskAwaiterAdapter.cs diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.cs rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.cs diff --git a/test/TaskTupleAwaiter.Tests/BehaviorComparisonTests.cs b/tests/unit/TaskTupleAwaiter.Tests/BehaviorComparisonTests.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/BehaviorComparisonTests.cs rename to tests/unit/TaskTupleAwaiter.Tests/BehaviorComparisonTests.cs diff --git a/test/TaskTupleAwaiter.Tests/CopyableSynchronizationContext.cs b/tests/unit/TaskTupleAwaiter.Tests/CopyableSynchronizationContext.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/CopyableSynchronizationContext.cs rename to tests/unit/TaskTupleAwaiter.Tests/CopyableSynchronizationContext.cs diff --git a/test/TaskTupleAwaiter.Tests/DummyException.cs b/tests/unit/TaskTupleAwaiter.Tests/DummyException.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/DummyException.cs rename to tests/unit/TaskTupleAwaiter.Tests/DummyException.cs diff --git a/test/TaskTupleAwaiter.Tests/On.cs b/tests/unit/TaskTupleAwaiter.Tests/On.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/On.cs rename to tests/unit/TaskTupleAwaiter.Tests/On.cs diff --git a/test/TaskTupleAwaiter.Tests/SpySynchronizationContext.cs b/tests/unit/TaskTupleAwaiter.Tests/SpySynchronizationContext.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/SpySynchronizationContext.cs rename to tests/unit/TaskTupleAwaiter.Tests/SpySynchronizationContext.cs diff --git a/test/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj b/tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj similarity index 69% rename from test/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj rename to tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj index 01ac4b3..c48f322 100644 --- a/test/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj +++ b/tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj @@ -3,6 +3,6 @@ runtime-async=on - + diff --git a/test/TaskTupleAwaiter.Tests/TaskTupleAwaiterTests.cs b/tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiterTests.cs similarity index 100% rename from test/TaskTupleAwaiter.Tests/TaskTupleAwaiterTests.cs rename to tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiterTests.cs