Skip to content

Add net9.0 TFM with span-based Task.WhenAll + BenchmarkDotNet harness#42

Merged
buvinghausen merged 33 commits into
masterfrom
worktree-net10-span-whenall
May 14, 2026
Merged

Add net9.0 TFM with span-based Task.WhenAll + BenchmarkDotNet harness#42
buvinghausen merged 33 commits into
masterfrom
worktree-net10-span-whenall

Conversation

@buvinghausen

Copy link
Copy Markdown
Owner

Summary

  • Adds net10.0 to the library's TargetFrameworks, giving .NET 10+ consumers a build where Task.WhenAll(...) calls inside the generated awaiters bind to Task.WhenAll(ReadOnlySpan<Task>) and stack-allocate the buffer, eliminating the per-await Task[] heap
    allocation that the params overload requires.
  • Generator change: Items helper now wraps its output as a collection expression so call sites emit Task.WhenAll([tasks.Item1, ..., tasks.ItemN]). On netstandard2.0/net462/net8.0 this still lowers to new Task[]{...} and binds to Task.WhenAll(params Task[]) — identical IL to before. On net10.0 it binds to the span overload.
  • Adds test/TaskTupleAwaiter.Benchmarks BenchmarkDotNet harness covering arities 2/4/8/16 across typed/non-generic tuples and pre-completed/async completion modes, with ConfigureAwait(bool) and ConfigureAwait(ConfigureAwaitOptions) variants. [MemoryDiagnoser]
    on every class so allocation deltas are visible.
  • AOT smoke test adds net10.0 to its TFM list so the new code path is exercised under NativeAOT publish in CI.
  • test/Directory.Build.props scoped to TaskTupleAwaiter.Tests only — the multi-TFM (net11.0;...;net472) and xUnit/Shouldly inheritance previously leaked into BenchmarkDotNet's auto-generated wrapper csproj (which lives under bin/) and broke restore with
    NU1201. Other test-tree projects now provide their own TFMs explicitly.
  • Library jumps from 3 lib folders to 4 in the NuGet package: lib/netstandard2.0, lib/net462, lib/net8.0, lib/net10.0. .NET 9 consumers continue to receive the lib/net8.0 asset.

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) to Task.WhenAll(ReadOnlySpan<Task>) on net10.0 whenever both overloads are visible. That means adding the net10.0 TFM alone delivered the allocation
reduction
— 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 Allocated results (TypedTuple Arity2_PreCompleted through Arity16_PreCompleted):

TFM A2 A4 A8 A16
net8.0 (baseline & after) 120 B 136 B 168 B 808 B
net10.0 (baseline & after) 72 B 72 B 72 B 648 B

Test plan

  • CI green on all build matrix entries.
  • AOT smoke test publishes successfully on net8.0, net10.0, and net11.0.
  • All 2431 tests pass on net472, net8.0, net9.0, net10.0, net11.0.
  • Sanity check the produced .nupkg lists four lib/ folders.

Notes

  • Spec: docs/superpowers/specs/2026-05-13-net10-span-whenall-design.md
  • Plan: docs/superpowers/plans/2026-05-13-net10-span-whenall.md
  • Benchmark numbers (before + after): docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md

buvinghausen and others added 15 commits May 13, 2026 16:40
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>
@buvinghausen buvinghausen requested a review from jnm2 May 13, 2026 22:39
@buvinghausen

Copy link
Copy Markdown
Owner Author

@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 jnm2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great to me. We could also consider using array pooling to achieve similar benefits on pre-net-10 targets.

Comment thread src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs Outdated
Comment thread test/Directory.Build.props Outdated
Comment thread docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md Outdated
@buvinghausen

Copy link
Copy Markdown
Owner Author

@jnm2 ok shuffled the projects around so that Claude's MS build nightmare is over. Feel free to merge whenever you want.

@buvinghausen

Copy link
Copy Markdown
Owner Author

@jnm2 ok the build is back2good (DC Metro joke from their 2017/18 marketing campaign) the floor is yours

buvinghausen and others added 3 commits May 14, 2026 01:45
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>
@buvinghausen

Copy link
Copy Markdown
Owner Author
image

Ok now officially in your court my dude

@jnm2

jnm2 commented May 14, 2026

Copy link
Copy Markdown
Collaborator

@copilot Please go through all .md files and code comments in this PR and remove all mentions in comments of collection expressions or [ as being related to whether the span overload is picked. Compiling against net10.0 was the necessary and sufficient condition for getting the span overload, and this is what should be stated everywhere rather than talking about collection expressions or syntactic explicitness. [ ] is not an explicit "pick span" syntax at all.

Copilot finished work on behalf of jnm2 May 14, 2026 16:13
Copilot AI requested a review from jnm2 May 14, 2026 16:13
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Co-authored-by: Joseph Musser <me@jnm2.com>
Comment thread src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs
Comment thread docs/superpowers/specs/2026-05-13-net10-span-whenall-design.md
Copilot finished work on behalf of jnm2 May 14, 2026 16:30
Copilot AI requested a review from jnm2 May 14, 2026 16:30
Comment thread README.md Outdated
Comment thread docs/superpowers/specs/2026-05-13-net9-span-whenall-design.md Outdated
@jnm2 jnm2 changed the title Add net10.0 TFM with span-based Task.WhenAll + BenchmarkDotNet harness Add net9.0 TFM with span-based Task.WhenAll + BenchmarkDotNet harness May 14, 2026
Copilot finished work on behalf of jnm2 May 14, 2026 16:36
Copilot AI requested a review from jnm2 May 14, 2026 16:36
Comment thread README.md Outdated
Comment thread tests/unit/Directory.Build.props Outdated
Co-authored-by: Joseph Musser <me@jnm2.com>
@jnm2

jnm2 commented May 14, 2026

Copy link
Copy Markdown
Collaborator

@buvinghausen I updated the rationale and also retargeted the change to net9, since that's where span-based Task.WhenAll first appears.

@buvinghausen buvinghausen merged commit 712903a into master May 14, 2026
1 check passed
@buvinghausen buvinghausen deleted the worktree-net10-span-whenall branch May 14, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants