Repository navigation
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