Skip to content

v2.3.0 - .NET 9.0 span support + file size reduction

Latest

Choose a tag to compare

@buvinghausen buvinghausen released this 15 May 16:56
· 14 commits to master since this release
712903a

TaskTupleAwaiter 2.3.0

Adds a net9.0 target so .NET 9+ consumers get a span-based Task.WhenAll path with significantly lower per-await
allocations. Includes generator hygiene (drop record struct, inline trivial forwarders) and a BenchmarkDotNet harness
so future perf claims are measured rather than guessed.

Highlights

  • New net9.0 library TFM. The generated Task.WhenAll(...) call now binds to Task.WhenAll(ReadOnlySpan) on .NET
    9, stack-allocating the task buffer instead of heap-allocating a Task[] per await. Existing netstandard2.0 / net462 /
    net8.0 assets are unchanged — same IL as before.
  • Lighter awaiter types. Generated awaiters are now plain readonly struct instead of readonly record struct. Equality
    semantics were never needed for an awaiter; dropping them removed a substantial chunk of synthesized members per arity
    and pulled the net9.0 DLL down to 64 KB (from ~80–92 KB).
  • Aggressive inlining on trivial forwarders. IsCompleted, OnCompleted, UnsafeOnCompleted, the GetAwaiter /
    ConfigureAwait extension methods, and TupleConfiguredTaskAwaitable.GetAwaiter() are now
    [MethodImpl(AggressiveInlining)]. Constructors and GetResult are left un-annotated — their bodies grow with arity.
  • No source-level changes for consumers. Same extension methods, same shape, same semantics. Recompile against the new
    package and .NET 9 consumers automatically pick up the span path.

Benchmarks — .NET 9 vs .NET 8 (same source tree)

Captured on an Intel Core Ultra 9 185H with BenchmarkDotNet v0.15.8. Full numbers in
docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md.

Typed tuple, pre-completed Task (await (Task, Task, ...)):

┌───────┬──────────────┬──────────────┬──────┐
│ Arity │ net8.0 alloc │ net9.0 alloc │ Δ │
├───────┼──────────────┼──────────────┼──────┤
│ 2 │ 120 B │ 72 B │ −40% │
├───────┼──────────────┼──────────────┼──────┤
│ 4 │ 136 B │ 72 B │ −47% │
├───────┼──────────────┼──────────────┼──────┤
│ 8 │ 168 B │ 72 B │ −57% │
├───────┼──────────────┼──────────────┼──────┤
│ 16 │ 808 B │ 648 B │ −20% │
└───────┴──────────────┴──────────────┴──────┘

Non-generic tuple (await (Task, Task, ...)) at arity 16: 232 B → 72 B (−69%).

Mean time per op drops 13–34% on the typed-tuple pre-completed path (allocation elimination + JIT improvements between
.NET 8 and .NET 9 + the new inlining hints). The async (yielding) benchmarks see 4–12% lower allocations on net9.0;
the state-machine box dominates there, so the proportional win is smaller.

What's also in this release

  • BenchmarkDotNet harness at benches/TaskTupleAwaiter.Benchmarks/ covering typed and non-generic tuples at arities
    2/4/8/16, in pre-completed and yielding completion modes, plus ConfigureAwait(bool) /
    ConfigureAwait(ConfigureAwaitOptions) spot checks. Runs are local-only (not CI); see the project README for
    invocation.
  • AOT smoke test now covers net9.0 in addition to net8.0 / net11.0, so the span-WhenAll code path is exercised under
    NativeAOT publish.
  • Repo layout: tests moved to tests/unit/, AOT smoke test to tests/smoke/, benchmarks to benches/. No effect on the
    shipped package.

Compatibility

  • No API changes. All public surface is identical; this is a pure perf / packaging update.
  • No new dependencies for consumers.
  • Supported TFMs: netstandard2.0, net462, net8.0, net9.0 (new).
  • Runtime async (C# 15 / .NET 11) is a caller-side compile feature and works automatically — no library changes were
    needed.

Credits

Original concept by Joseph Musser (@jnm2 (https://github.com/jnm2)), whose review feedback also shaped the final
shipped state of this release.
Full Changelog: 2.2.0...2.3.0