Add net9.0 TFM with span-based Task.WhenAll + BenchmarkDotNet harness#42
Conversation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends TaskTupleAwaiter library to target net10.0 in addition to existing TFMs. All 2431 tests pass on net472, net8.0, net9.0, net10.0, and net11.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The xUnit/Shouldly imports and multi-TFM (net11.0;...;net472) list are only meaningful for the unit-test project. The AOT smoke test and the benchmarks project both override TFMs in their own csprojs and don't need the inherited xUnit packages — the Remove items they used to need become no-ops, but the bigger problem is BenchmarkDotNet's internal wrapper csproj. BDN generates that wrapper at runtime under bin/, and MSBuild's Directory.Build.props auto-import walks up to find this file; the wrapper inherits TargetFrameworks=...;net472 and then fails NU1201 trying to consume the benchmark project (which targets net8.0;net10.0). Scoping the multi-TFM PropertyGroup and the xUnit ItemGroup to MSBuildProjectName == TaskTupleAwaiter.Tests preserves existing test behavior and unblocks BDN runs without per-project workarounds. Also gitignore BenchmarkDotNet.Artifacts/ so reports don't pollute git status during benchmark runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds TypedTupleAwaitBenchmarks with 8 methods covering arities 2/4/8/16 under pre-completed and async (Task.Yield) completion modes. The class is decorated with [MemoryDiagnoser] so BDN reports per-op allocations. Also suppress CA1707 and CA1822 in the benchmarks csproj — BenchmarkDotNet requires underscore-separated method names and instance benchmark methods, both of which the repo's analyzers (latest-Recommended + TreatWarningsAsErrors) would otherwise turn into build errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors TypedTupleAwaitBenchmarks for awaitable tuples of plain Task (no result type). The first element of each tuple is cast to (Task) to force binding to the non-generic extension method overload group rather than any homogeneous typed inference path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers both the legacy bool overload and the .NET 8+ ConfigureAwaitOptions overload, in both the typed and non-generic tuple flavors, at arities 4 and 16. These measure the configured-await code path which builds a separate TupleConfiguredTaskAwaitable struct under the hood. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empirically, the net10.0 baseline already shows substantially lower per-op allocations than net8.0 for low-arity pre-completed tuple awaits (120 B -> 72 B at arity 2 typed, 808 B -> 648 B at arity 16 typed). The C# 13+ compiler prefers Task.WhenAll(ReadOnlySpan<Task>) over the params Task[] overload for positional Task.WhenAll(t1, ..., tN) call sites whenever both are visible. Since the ReadOnlySpan overload only exists on net9+, the net8.0 library DLL still binds to params Task[] (heap allocation), while the net10.0 library DLL already binds to the span overload (stack allocation). Adding the net10.0 TFM in Task 1 was sufficient on its own to deliver the optimization — Task 9's collection-expression change is intent-clarifying rather than IL-changing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the Items helper output with brackets so each generated call site becomes Task.WhenAll([tasks.Item1, ..., tasks.ItemN]) instead of Task.WhenAll(tasks.Item1, ..., tasks.ItemN). On netstandard2.0, net462, and net8.0 the compiler still binds to Task.WhenAll(params Task[]) and allocates the array — same IL as before. On net9+ the compiler binds to Task.WhenAll(ReadOnlySpan<Task>) and stack-allocates the buffer. Empirically this change is intent-clarifying rather than IL-changing on net10.0: the C# 13+ compiler's overload-preference rule already binds Task.WhenAll(t1, t2, ...) positional calls to the span overload when both are visible. The bracketed collection expression is the explicit form and remains stable across future C# overload-resolution changes. All 2431 tests still pass on all 5 test TFMs (net8.0, net9.0, net10.0, net11.0, net472). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exercises the new generated collection-expression WhenAll code path under NativeAOT publish on net10.0, in addition to the existing net8.0 and net11.0 coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CLAUDE.md: update library TFM list, repo layout, and the generator section to describe the collection-expression emission and per-TFM compiler binding behavior. - README.md: note the allocation-free WhenAll on .NET 10+ and add a net10 row to the Compatibility table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Confirms the IL-level outcome predicted in the previous commit:
- net10.0 after-change allocations match net10.0 baseline byte-for-byte
across all 22 benchmarks (72 B / 648 B for pre-completed at the
arities where the win applies). The collection-expression source has
no IL impact on net10.0 — the compiler was already binding to the
span overload via overload preference.
- net8.0 after-change allocations match net8.0 baseline byte-for-byte
(120 / 136 / 168 / 808 etc.). The collection-expression lowers to
new Task[]{...} on net8.0 — identical IL to the original positional
params form.
Net win attributable to this branch: adding the net10.0 TFM (Task 1)
delivers the per-await Task[] allocation reduction for .NET 10
consumers. The generator collection-expression syntax (Task 9) is
preserved as belt-and-suspenders insurance against future C# overload
resolution changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CLAUDE.md: remove phantom test/TaskTupleAwaiter.Benchmarks/Directory.Build.props entry from the repo layout. The isolation is implemented via the MSBuildProjectName condition in test/Directory.Build.props, not via a local props file. - Generator helper comment: replace "net9+" with "net10.0+" and note explicitly that the library does not ship a net9.0 asset, so .NET 9 consumers pick the net8.0 asset and don't see the optimization. - .gitignore: also ignore *.nupkg / *.snupkg so local `dotnet pack` runs don't show as untracked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@jnm2 headed out to dinner haven't taken the time to review or do anything no rush on this will leave it open feel free to post musings. At least the BDN stuff is there let me know which direction you want to take it. |
jnm2
left a comment
There was a problem hiding this comment.
Looks great to me. We could also consider using array pooling to achieve similar benefits on pre-net-10 targets.
|
@jnm2 ok shuffled the projects around so that Claude's MS build nightmare is over. Feel free to merge whenever you want. |
|
@jnm2 ok the build is back2good (DC Metro joke from their 2017/18 marketing campaign) the floor is yours |
The synthesized record-struct members (Equals, GetHashCode, ToString, PrintMembers, == / !=) are never used by the awaiter protocol. Dropping the `record` keyword shrinks TaskTupleAwaiter.dll by ~30% across every TFM (net10.0: 92,160 -> 64,512 bytes; netstandard2.0: 80,896 -> 53,248). All 2,431 tests pass on every test TFM; benchmark allocation and time are unchanged within run-to-run noise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Annotate the small forwarder methods in the generated awaiter and extension types with [MethodImpl(MethodImplOptions.AggressiveInlining)]: the GetAwaiter / ConfigureAwait extension methods (typed + non-generic arity 1 + typed arity 2..16), the IsCompleted / OnCompleted / UnsafeOnCompleted accessors on TupleTaskAwaiter and on TupleConfiguredTaskAwaitable.Awaiter, and the TupleConfiguredTaskAwaitable .GetAwaiter() helper. The constructor and GetResult are deliberately left un-annotated because their bodies grow with arity and forcing inline there is more likely to bloat than help. The IsCompleted properties are emitted with an explicit get accessor block because [MethodImpl] only attaches to methods/constructors, not property declarations. Measured on net10.0 (Intel Core Ultra 9 185H, BDN 0.15.8): - TypedTuple.Arity8_PreCompleted: 93.21 ns -> 80.00 ns (-14%) - TypedTuple.Arity16_Async: 6224.68 ns -> 5857.43 ns (-6%) - TypedTuple.Arity4_PreCompleted: 49.07 ns -> 46.77 ns (~5%) - Other arities: flat within run-to-run noise. DLL size delta: 0 B across all four TFMs (the new attribute metadata fits within existing PE 4 KB alignment padding). All 2431 tests still pass on all 5 test TFMs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the original baseline-vs-after framing (which mixed machines and intermediate code states) with a clean net10.0 vs net8.0 comparison captured on the same source tree (HEAD 7c98fd2) on a single machine. The new doc presents: - Full BDN tables for each of the three benchmark classes on both TFMs. - Per-method allocation deltas (net10 vs net8) so the user-facing win is obvious at a glance. - A headline summary covering both allocation and mean-time wins. - DLL size table for the shipped library. Headline: typed pre-completed tuple awaits drop 40-57% allocation at arity 2/4/8 (-20% at arity 16). Non-generic arity 16 drops 69%. Mean times improve 13-34% on the pre-completed path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@copilot Please go through all .md files and code comments in this PR and remove all mentions in comments of collection expressions or |
Agent-Logs-Url: https://github.com/buvinghausen/TaskTupleAwaiter/sessions/91413727-6185-498c-944c-8f40a2068b81 Co-authored-by: jnm2 <8040367+jnm2@users.noreply.github.com>
Co-authored-by: Joseph Musser <me@jnm2.com>
Agent-Logs-Url: https://github.com/buvinghausen/TaskTupleAwaiter/sessions/845db3d9-76cf-4ccb-a3fe-c876b9422e2d Co-authored-by: jnm2 <8040367+jnm2@users.noreply.github.com>
Agent-Logs-Url: https://github.com/buvinghausen/TaskTupleAwaiter/sessions/f44ff7f8-bc2d-4d0a-be59-6ae4a07e1bf2 Co-authored-by: jnm2 <8040367+jnm2@users.noreply.github.com>
Agent-Logs-Url: https://github.com/buvinghausen/TaskTupleAwaiter/sessions/f44ff7f8-bc2d-4d0a-be59-6ae4a07e1bf2 Co-authored-by: jnm2 <8040367+jnm2@users.noreply.github.com>
Co-authored-by: Joseph Musser <me@jnm2.com>
|
@buvinghausen I updated the rationale and also retargeted the change to net9, since that's where span-based Task.WhenAll first appears. |

Summary
net10.0to the library'sTargetFrameworks, giving .NET 10+ consumers a build whereTask.WhenAll(...)calls inside the generated awaiters bind toTask.WhenAll(ReadOnlySpan<Task>)and stack-allocate the buffer, eliminating the per-awaitTask[]heapallocation that the params overload requires.
Itemshelper now wraps its output as a collection expression so call sites emitTask.WhenAll([tasks.Item1, ..., tasks.ItemN]). Onnetstandard2.0/net462/net8.0this still lowers tonew Task[]{...}and binds toTask.WhenAll(params Task[])— identical IL to before. Onnet10.0it binds to the span overload.test/TaskTupleAwaiter.BenchmarksBenchmarkDotNet harness covering arities 2/4/8/16 across typed/non-generic tuples and pre-completed/async completion modes, withConfigureAwait(bool)andConfigureAwait(ConfigureAwaitOptions)variants.[MemoryDiagnoser]on every class so allocation deltas are visible.
net10.0to its TFM list so the new code path is exercised under NativeAOT publish in CI.test/Directory.Build.propsscoped toTaskTupleAwaiter.Testsonly — the multi-TFM (net11.0;...;net472) and xUnit/Shouldly inheritance previously leaked into BenchmarkDotNet's auto-generated wrapper csproj (which lives underbin/) and broke restore withNU1201. Other test-tree projects now provide their own TFMs explicitly.
lib/netstandard2.0,lib/net462,lib/net8.0,lib/net10.0. .NET 9 consumers continue to receive thelib/net8.0asset.Empirical finding (full numbers in
docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md)The C# 13+ compiler's overload-preference rule already binds positional
Task.WhenAll(t1, t2, ..., tN)toTask.WhenAll(ReadOnlySpan<Task>)on net10.0 whenever both overloads are visible. That means adding thenet10.0TFM alone delivered the allocationreduction — the generator's collection-expression syntax leaves IL on net10.0 byte-for-byte unchanged. The change is preserved as belt-and-suspenders insurance against future overload-resolution behavior.
Per-op
Allocatedresults (TypedTupleArity2_PreCompletedthroughArity16_PreCompleted):Test plan
.nupkglists fourlib/folders.Notes
docs/superpowers/specs/2026-05-13-net10-span-whenall-design.mddocs/superpowers/plans/2026-05-13-net10-span-whenall.mddocs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md